diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 965bd5b..72013c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,42 +26,34 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v2 with: - version: 8 + version: 10.7.1 - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - - name: Build aura-protocol first - run: | - cd packages/aura-protocol - npm run build - - - name: Generate schema files - run: | - cd packages/aura-protocol - npm run generate-schema + - name: Build aura-protocol + run: pnpm --filter aura-protocol build - - name: Verify schema files exist - run: | - ls -la packages/aura-protocol/dist/ - test -f packages/aura-protocol/dist/aura-v1.0.schema.json - - - name: Build all packages - run: pnpm build - - - name: Build reference-server for production - run: pnpm --filter aura-reference-server build - - - name: Run unit & integration tests + - name: Build and Test run: | + # Build reference server and client if needed (or rely on just-in-time build if configured, but explicit build is safer) + # The original CI built everything, let's keep it safe but optimized + pnpm --filter aura-reference-server build + pnpm --filter aura-reference-client build + + # Start server in background pnpm --filter aura-reference-server start & - # Wait for the server to be ready with health check + SERVER_PID=$! + + # Wait for potential server startup timeout 60 bash -c 'until curl -f http://localhost:3000/api/posts > /dev/null 2>&1; do sleep 2; done' + + # Run all tests + echo "Running Unit and Integration Tests..." pnpm test - - - name: Run End-to-End Tests - run: | - pnpm --filter aura-reference-server start & - # Wait for the server to be ready with health check - timeout 60 bash -c 'until curl -f http://localhost:3000/api/posts > /dev/null 2>&1; do sleep 2; done' - pnpm --filter aura-reference-client test-workflow http://localhost:3000 \ No newline at end of file + + echo "Running End-to-End Tests..." + pnpm --filter aura-reference-client test-workflow http://localhost:3000 + + # Stop server + kill $SERVER_PID \ No newline at end of file diff --git a/README.md b/README.md index 47c47b2..28bdeac 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,112 @@ -# AURA: The Protocol for a Machine-Readable Web +# AURA: Agent Usable Resource Assertion -**AURA (Agent-Usable Resource Assertion)** is an open protocol for making websites understandable and operable by AI agents. It proposes a new standard for AI-web interaction that moves beyond fragile screen scraping and DOM manipulation towards a robust, secure, and efficient machine-readable layer for the internet. - -The web was built for human eyes. AURA is a specification for giving it a machine-readable "API". +AURA is an open protocol for making a website's capabilities machine-readable and safe to act on. Instead of scraping UIs, agents read a manifest and call explicit HTTP actions. [![NPM Version](https://img.shields.io/npm/v/@aura/protocol.svg)](https://www.npmjs.com/package/aura-protocol) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ---- - -## The Vision: Why AURA? - -Current AI agents interact with websites in a brittle and inefficient way: -1. **Screen Scraping:** They "look" at pixels and guess where to click. This is slow, expensive, and breaks with the slightest UI change. -2. **DOM Manipulation:** They parse complex HTML structures, which are inconsistent across sites and change frequently. -3. **Insecurity:** Website owners have no control over what an agent might do. - -AURA solves this by allowing websites to **declare their capabilities** in a simple, standardized `aura.json` manifest file. - -Instead of an agent guessing how to "create a post," the website explicitly states: -> *"I have a capability named `create_post`. It's an `HTTP POST` to `/api/posts` and requires `title` and `content` parameters."* - -This is a fundamental paradigm shift from *imperative guessing* to *declarative interaction*. - -## Core Concepts - -* **Manifest (`aura.json`):** A file served at `/.well-known/aura.json` that acts as a site's "API documentation" for AI agents. It defines all available resources and capabilities. -* **Capability:** A single, discrete action an agent can perform (e.g., `list_posts`, `login`, `update_profile`). Each capability maps to a specific HTTP request. -* **State (`AURA-State` Header):** A dynamic HTTP header sent by the server with each response, informing the agent about the current context (e.g., is the user authenticated?) and which capabilities are currently available to them. +## How AURA Works -## This Repository +- **Manifest (`aura.json`)**: A site serves a machine-readable manifest at `/.well-known/aura.json`. +- **Capabilities**: Each capability describes a single HTTP action with parameters and a URL template. +- **AURA-State header**: Each response can include an `AURA-State` header to describe the current context (for example, whether the user is authenticated). -This repository is the **canonical specification for the AURA protocol**. It provides the core building blocks for the AURA ecosystem: +## Packages in This Repo -* **`packages/aura-protocol`**: The core `@aura/protocol` NPM package, containing TypeScript interfaces and the official JSON Schema for validation. **This is the heart of AURA.** -* **`packages/reference-server`**: A reference implementation of an AURA-enabled server built with Next.js. Use this to understand how to make your own website AURA-compliant. -* **`packages/reference-client`**: A minimal, backend-only reference client demonstrating two powerful ways to consume the protocol, without any browser or extension required. - -## Getting Started: A 5-Minute Demonstration +- `packages/aura-protocol`: TypeScript interfaces and the JSON Schema published as `@aura/protocol` (current version 1.0.3 on npm). +- `packages/reference-server`: A reference Next.js server showing how to serve a manifest and capabilities. It is a demo only and is not a production dependency. +- `packages/reference-client`: A reference client and test workflow that consume the protocol. -See the protocol in action. +## Quickstart (Local Demo) -### 1. Install Dependencies - -From the root of the monorepo, install all necessary dependencies for all packages. +Install and run the reference server: ```bash pnpm install +pnpm --filter aura-reference-server dev ``` -### 2. Run the Reference Server - -The server is a sample website that "speaks" AURA. +Then verify the manifest: ```bash -# This will start the server (usually on http://localhost:3000) -pnpm --filter aura-reference-server dev +curl http://localhost:3000/.well-known/aura.json ``` -You can now visit http://localhost:3000/.well-known/aura.json in your browser to see the manifest. +## Practical Examples -### 3. Run the Reference Agent +### Login and Authenticated Action (curl) + +```bash +# Save the auth cookie after login +curl -i -c cookies.txt \ + -H "Content-Type: application/json" \ + -d '{"email":"demo@aura.dev","password":"password123"}' \ + http://localhost:3000/api/auth/login + +# Use the cookie to create a post +curl -i -b cookies.txt \ + -H "Content-Type: application/json" \ + -d '{"title":"Hello","content":"From AURA"}' \ + http://localhost:3000/api/posts +``` -This simple agent uses an LLM to understand a prompt and execute a capability on the server. +### Use the Reference Client -First, create a `.env` file inside the `packages/reference-client` directory and add your OpenAI API key. +Create `packages/reference-client/.env`: ``` OPENAI_API_KEY="sk-..." ``` -Then, run the agent with a URL and a prompt: +Run an agent prompt: ```bash -# (In a new terminal) -pnpm --filter aura-reference-client agent -- http://localhost:3000 "list all the blog posts" +pnpm --filter aura-reference-client agent -- http://localhost:3000 "log in and create a post titled Hello" ``` -Observe how the agent fetches the manifest, plans its action, and executes the list_posts capability directly. - -### 4. Run the Crawler (The Big Vision) - -This script demonstrates how a search engine could index an AURA-enabled site, understanding its functions, not just its content. - -```bash -# In the client directory -pnpm --filter aura-reference-client crawler -- http://localhost:3000 +### Manifest Snippet (Login Capability) + +```json +{ + "capabilities": { + "login": { + "id": "login", + "v": 1, + "description": "Authenticate user with email and password", + "parameters": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email" }, + "password": { "type": "string", "minLength": 8 } + } + }, + "action": { + "type": "HTTP", + "method": "POST", + "urlTemplate": "/api/auth/login", + "encoding": "json", + "parameterMapping": { + "email": "/email", + "password": "/password" + } + } + } + } +} ``` -The output shows a structured JSON object representing the site's capabilities. This is the future of search: indexing actions, not just pages. +## Build and Test -### 5. Run the Automated Tests & Coverage - -The repository ships with a full Vitest suite that exercises the protocol schemas, the reference server API routes, and the URI-template utilities. +Build everything: ```bash -# From the repo root -pnpm test --run +pnpm run build ``` -An HTML coverage report will be generated in `coverage/`. - ---- - -## Building Everything - -If you want to make sure the whole monorepo compiles (TypeScript + schema generation), simply run: +Run tests (requires the reference server running on `http://localhost:3000`): ```bash -pnpm run build +pnpm test +pnpm --filter aura-reference-client test-workflow http://localhost:3000 ``` - -This triggers the build in every workspace package and regenerates the official JSON Schemas in `packages/aura-protocol/dist/`. - -## The Future is a Collaborative Ecosystem - -This repository defines the standard. The true power of AURA will be realized when a community builds on top of it. We envision a future with: - -* **Adapters** for all major web frameworks (Express, Laravel, Django, Ruby on Rails). -* **Clients** in every major language (Python, Go, Rust, Java). -* **Intelligent Applications** - -AURA is a public good. Fork it, build with it, and help us create a more intelligent and interoperable web. \ No newline at end of file diff --git a/aura_mcp-poem.md b/aura_mcp-poem.md deleted file mode 100644 index e3aa7d3..0000000 --- a/aura_mcp-poem.md +++ /dev/null @@ -1,9 +0,0 @@ -/* - A will to act, in silent code conferred, - - A context dawns, the unspoken word. - - The web now knows itself, through agent's grace, - - A conscious web, beyond mere time and place. -*/ diff --git a/packages/aura-protocol/scripts/generate-schema.ts b/packages/aura-protocol/scripts/generate-schema.ts index bac784b..4450af2 100644 --- a/packages/aura-protocol/scripts/generate-schema.ts +++ b/packages/aura-protocol/scripts/generate-schema.ts @@ -84,7 +84,7 @@ if (schema.definitions) { if (resourceSchema && !schema.definitions[resourceSchemaName]) { schema.definitions[resourceSchemaName] = resourceSchema; } - + if (capabilitySchema && !schema.definitions[capabilitySchemaName]) { schema.definitions[capabilitySchemaName] = capabilitySchema; } @@ -120,7 +120,7 @@ if (schema.definitions) { if (capabilityDef && typeof capabilityDef === 'object' && 'definitions' in capabilityDef && capabilityDef.definitions) { console.log('Moving JSONSchema definition to top level...'); const nestedDefs = capabilityDef.definitions as any; - + if (nestedDefs['JSONSchema']) { schema.definitions['JSONSchema'] = nestedDefs['JSONSchema']; } @@ -133,7 +133,15 @@ if (schema.definitions) { if (nestedDefs['Record']) { schema.definitions['Record'] = nestedDefs['Record']; } - + if (nestedDefs['ParameterLocation']) { + schema.definitions['ParameterLocation'] = nestedDefs['ParameterLocation']; + } + + const paramLocRecordKey = Object.keys(nestedDefs).find(k => k.includes('Record')); + if (paramLocRecordKey) { + schema.definitions[paramLocRecordKey] = nestedDefs[paramLocRecordKey]; + } + // Remove the nested definitions to avoid duplication delete capabilityDef.definitions; } @@ -142,9 +150,9 @@ if (schema.definitions) { // Remove non-standard keywords to ensure schema portability function purgeDefaultProps(obj: any) { if (typeof obj !== 'object' || obj === null) return; - + delete obj.defaultProperties; - + for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { purgeDefaultProps(obj[key]); @@ -167,7 +175,7 @@ additionalTypes.forEach(typeName => { if (typeSchema) { // Clean up non-standard properties for additional schemas too purgeDefaultProps(typeSchema); - + const typeOutputPath = path.join(outputDir, `${typeName.toLowerCase()}.schema.json`); fs.writeFileSync(typeOutputPath, JSON.stringify(typeSchema, null, 2)); console.log(`Schema for ${typeName} generated at: ${typeOutputPath}`); diff --git a/packages/aura-protocol/src/index.test.ts b/packages/aura-protocol/src/index.test.ts index 48447a6..844713f 100644 --- a/packages/aura-protocol/src/index.test.ts +++ b/packages/aura-protocol/src/index.test.ts @@ -11,11 +11,11 @@ describe('AURA Protocol JSON Schema Validation', () => { beforeAll(() => { // Load the generated JSON schema const schemaPath = path.join(__dirname, '../dist/aura-v1.0.schema.json'); - + if (!fs.existsSync(schemaPath)) { throw new Error(`Schema file not found at ${schemaPath}. Run 'npm run build' first.`); } - + schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); ajv = new Ajv.default({ allErrors: true, strict: false }); }); @@ -132,10 +132,10 @@ describe('AURA Protocol JSON Schema Validation', () => { expect(valid).toBe(false); expect(validate.errors).not.toBeNull(); expect(validate.errors!.length).toBeGreaterThan(0); - + // Should have an error about protocol value - const protocolError = validate.errors!.find(error => - error.instancePath === '/protocol' || + const protocolError = validate.errors!.find(error => + error.instancePath === '/protocol' || (error.instancePath === '' && error.message?.includes('protocol')) ); expect(protocolError).toBeDefined(); @@ -160,9 +160,9 @@ describe('AURA Protocol JSON Schema Validation', () => { expect(valid).toBe(false); expect(validate.errors).not.toBeNull(); expect(validate.errors!.length).toBeGreaterThan(0); - + // Should have an error about missing name field - const nameError = validate.errors!.find(error => + const nameError = validate.errors!.find(error => error.instancePath === '/site' && error.message?.includes('name') ); expect(nameError).toBeDefined(); @@ -319,4 +319,58 @@ describe('AURA Protocol JSON Schema Validation', () => { // The main schema is currently permissive for resources and capabilities expect(valid).toBe(true); }); + + it('should validate manifests with parameterLocation field in HttpAction', () => { + const manifest = { + $schema: 'https://aura.dev/schemas/v1.0.json', + protocol: 'AURA', + version: '1.0', + site: { + name: 'Test Site', + url: 'https://example.com' + }, + resources: { + search: { + uriPattern: '/search', + description: 'Search', + operations: { + GET: { capabilityId: 'search' } + } + } + }, + capabilities: { + search: { + id: 'search', + v: 1, + description: 'Search items', + parameters: { + type: 'object', + properties: { + q: { type: 'string' }, + page: { type: 'number' } + } + }, + action: { + type: 'HTTP', + method: 'GET', + urlTemplate: '/search', + parameterLocation: { + q: 'query', + page: 'header' + }, + parameterMapping: { + q: '/q', + page: '/page' + } + } + } + } + }; + + const validate = ajv.compile(schema); + const valid = validate(manifest); + + expect(valid).toBe(true); + expect(validate.errors).toBeNull(); + }); }); \ No newline at end of file diff --git a/packages/aura-protocol/src/index.ts b/packages/aura-protocol/src/index.ts index fe50091..7a60d89 100644 --- a/packages/aura-protocol/src/index.ts +++ b/packages/aura-protocol/src/index.ts @@ -72,6 +72,11 @@ export interface JSONSchema { additionalProperties?: boolean | JSONSchema; } +/** + * ParameterLocation - Valid locations for parameters in HTTP requests + */ +export type ParameterLocation = 'path' | 'query' | 'header' | 'body'; + /** * HttpAction - Defines how to execute a capability via HTTP * Includes security, encoding, and parameter mapping @@ -85,6 +90,9 @@ export interface HttpAction { cors?: boolean; // Defines how parameters are sent encoding?: 'json' | 'query'; + // Defines explicit location for parameters. + // Precedence: path > query > header > body + parameterLocation?: Record; // Maps capability parameters to the HTTP request using JSON-Pointer syntax parameterMapping: Record; } @@ -113,4 +121,3 @@ export interface AuraState { capabilities?: string[]; // Available capability IDs for current state } - \ No newline at end of file diff --git a/packages/reference-client/src/.aura-cookies.json b/packages/reference-client/src/.aura-cookies.json index 4b6d3ef..f9d676e 100644 --- a/packages/reference-client/src/.aura-cookies.json +++ b/packages/reference-client/src/.aura-cookies.json @@ -5,5 +5,17 @@ "enableLooseMode": false, "allowSpecialUseDomain": true, "prefixSecurity": "silent", - "cookies": [] + "cookies": [ + { + "key": "auth-token", + "expires": "1970-01-01T00:00:00.000Z", + "domain": "localhost", + "path": "/", + "httpOnly": true, + "hostOnly": true, + "creation": "2026-01-19T14:08:15.543Z", + "lastAccessed": "2026-01-19T14:08:17.095Z", + "sameSite": "lax" + } + ] } \ No newline at end of file diff --git a/packages/reference-client/src/agent.test.ts b/packages/reference-client/src/agent.test.ts index c358289..f3b758d 100644 --- a/packages/reference-client/src/agent.test.ts +++ b/packages/reference-client/src/agent.test.ts @@ -12,7 +12,7 @@ vi.mock('openai', () => { // Ensure the OpenAI client can initialize in the agent module during tests process.env.NODE_ENV = 'test'; process.env.OPENAI_API_KEY = process.env.OPENAI_API_KEY || 'sk-test-key'; -import { prepareUrlPath, mapParameters, resolveJsonPointer } from './agent'; +import { prepareUrlPath, mapParameters, resolveJsonPointer, splitParametersByLocation } from './agent'; import axios from 'axios'; import { wrapper } from 'axios-cookiejar-support'; import { CookieJar } from 'tough-cookie'; @@ -386,6 +386,44 @@ describe('AURA Agent Core Functions', () => { }); }); }); + + describe('splitParametersByLocation', () => { + it('should split parameters into explicit buckets and leave unassigned', () => { + const params = { + id: '123', + q: 'search', + token: 'tok_abc', + content: 'hello world', + extra: 'keep' + }; + + const parameterLocation = { + id: 'path', + q: 'query', + token: 'header', + content: 'body' + } as const; + + const result = splitParametersByLocation(params, parameterLocation); + + expect(result.path).toEqual({ id: '123' }); + expect(result.query).toEqual({ q: 'search' }); + expect(result.header).toEqual({ token: 'tok_abc' }); + expect(result.body).toEqual({ content: 'hello world' }); + expect(result.unassigned).toEqual({ extra: 'keep' }); + }); + + it('should treat all parameters as unassigned when no parameterLocation is provided', () => { + const params = { id: '123', q: 'search' }; + const result = splitParametersByLocation(params); + + expect(result.path).toEqual({}); + expect(result.query).toEqual({}); + expect(result.header).toEqual({}); + expect(result.body).toEqual({}); + expect(result.unassigned).toEqual(params); + }); + }); }); describe('AURA Integration Tests', () => { @@ -749,4 +787,4 @@ describe('End-to-End Workflow Tests', () => { expect(auraState.isAuthenticated).toBe(true); expect(auraState.capabilities).toContain('create_post'); }); -}); \ No newline at end of file +}); diff --git a/packages/reference-client/src/agent.ts b/packages/reference-client/src/agent.ts index 82bb548..1a3ab95 100644 --- a/packages/reference-client/src/agent.ts +++ b/packages/reference-client/src/agent.ts @@ -4,7 +4,7 @@ import axios from 'axios'; import { wrapper } from 'axios-cookiejar-support'; import { CookieJar } from 'tough-cookie'; import OpenAI from 'openai'; -import { AuraManifest, AuraState } from 'aura-protocol'; +import { AuraManifest, AuraState, ParameterLocation } from 'aura-protocol'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -289,6 +289,64 @@ export function mapParameters(args: any, parameterMapping: Record; + query: Record; + header: Record; + body: Record; + unassigned: Record; +} + +/** + * Splits parameters into path/query/header/body buckets based on parameterLocation. + * Any parameters without an explicit location are returned in "unassigned". + */ +export function splitParametersByLocation( + params: Record, + parameterLocation?: Record +): ParameterBuckets { + const buckets: ParameterBuckets = { + path: {}, + query: {}, + header: {}, + body: {}, + unassigned: {} + }; + + if (!parameterLocation || Object.keys(parameterLocation).length === 0) { + buckets.unassigned = { ...params }; + return buckets; + } + + for (const [key, value] of Object.entries(params)) { + const location = parameterLocation[key]; + if (!location) { + buckets.unassigned[key] = value; + continue; + } + + switch (location) { + case 'path': + buckets.path[key] = value; + break; + case 'query': + buckets.query[key] = value; + break; + case 'header': + buckets.header[key] = value; + break; + case 'body': + buckets.body[key] = value; + break; + default: + buckets.unassigned[key] = value; + break; + } + } + + return buckets; +} + /** * Resolves a JSON Pointer path to its value in the given object. * Implements RFC 6901 JSON Pointer specification. @@ -345,45 +403,60 @@ async function executeAction(baseUrl: string, manifest: AuraManifest, capability parametersToUse = mapParameters(args, capability.action.parameterMapping); } + const hasParameterLocation = !!(capability.action.parameterLocation && Object.keys(capability.action.parameterLocation).length > 0); + const parameterBuckets = splitParametersByLocation(parametersToUse, capability.action.parameterLocation); + const templateArgs = hasParameterLocation + ? { ...parameterBuckets.path, ...parameterBuckets.query, ...parameterBuckets.unassigned } + : parametersToUse; + // Expand URI template with proper RFC 6570 support - const expandedUrl = prepareUrlPath(capability.action.urlTemplate, parametersToUse); + const expandedUrl = prepareUrlPath(capability.action.urlTemplate, templateArgs); const fullUrl = `${baseUrl}${expandedUrl}`; // Determine request data based on encoding let requestData: any = null; let queryParams: any = null; + let requestHeaders: Record | undefined = undefined; + + const fallbackParams = hasParameterLocation ? parameterBuckets.unassigned : parametersToUse; // For URI templates that include query parameters (e.g., {?param1,param2}), // the expansion already handles them, so we only send body data for non-GET methods - if (capability.action.encoding === 'json') { - // Send parameters in request body as JSON - requestData = parametersToUse; - } else if (capability.action.encoding === 'query') { - // For explicit query encoding, check if the URL already has query parameters - // If the URI template expansion created query parameters, don't send duplicates - const urlObj = new URL(fullUrl, baseUrl); - const urlHasQueryParams = urlObj.search !== ''; - - if (!urlHasQueryParams) { - // Only send query params if the URL doesn't already have them from template expansion - queryParams = parametersToUse; + const urlObj = new URL(fullUrl, baseUrl); + const urlHasQueryParams = urlObj.search !== ''; + + if (hasParameterLocation) { + if (Object.keys(parameterBuckets.header).length > 0) { + requestHeaders = { ...parameterBuckets.header }; } - // If URL already has query params from template expansion, don't send additional ones - } else { - // Fallback to method-based logic for capabilities without explicit encoding - if (capability.action.method === 'GET' || capability.action.method === 'DELETE') { - // For GET/DELETE, only use query params if not already in the URL template - const urlObj = new URL(fullUrl, baseUrl); - const hasQueryInTemplate = urlObj.search !== ''; - - if (!hasQueryInTemplate) { - queryParams = parametersToUse; + + if (Object.keys(parameterBuckets.query).length > 0 && !urlHasQueryParams) { + queryParams = { ...parameterBuckets.query }; + } + + if (Object.keys(parameterBuckets.body).length > 0) { + requestData = { ...parameterBuckets.body }; + } + } + + if (Object.keys(fallbackParams).length > 0) { + if (capability.action.encoding === 'json') { + requestData = requestData ? { ...requestData, ...fallbackParams } : fallbackParams; + } else if (capability.action.encoding === 'query') { + if (!urlHasQueryParams) { + queryParams = queryParams ? { ...queryParams, ...fallbackParams } : fallbackParams; } } else { - // For POST/PUT, send as body unless query parameters are in the template - const hasQueryInTemplate = fullUrl.includes('?'); - if (!hasQueryInTemplate) { - requestData = parametersToUse; + // Fallback to method-based logic for capabilities without explicit encoding + if (capability.action.method === 'GET' || capability.action.method === 'DELETE') { + if (!urlHasQueryParams) { + queryParams = queryParams ? { ...queryParams, ...fallbackParams } : fallbackParams; + } + } else { + const hasQueryInTemplate = fullUrl.includes('?'); + if (!hasQueryInTemplate) { + requestData = requestData ? { ...requestData, ...fallbackParams } : fallbackParams; + } } } } @@ -397,6 +470,7 @@ async function executeAction(baseUrl: string, manifest: AuraManifest, capability url: fullUrl, data: requestData, params: queryParams, + headers: requestHeaders, validateStatus: () => true, // Accept all status codes }); @@ -500,4 +574,4 @@ async function main() { // Avoid executing the CLI flow when the module is imported in a test environment if (process.env.NODE_ENV !== 'test') { main(); -} \ No newline at end of file +} diff --git a/packages/reference-server/lib/validator.test.ts b/packages/reference-server/lib/validator.test.ts index ea3630b..6bf5a19 100644 --- a/packages/reference-server/lib/validator.test.ts +++ b/packages/reference-server/lib/validator.test.ts @@ -153,13 +153,14 @@ function createMockRequest(options: { method?: string; query?: Record; body?: any; + headers?: Record; }): NextApiRequest { return { method: options.method || 'GET', query: options.query || {}, body: options.body || {}, url: '', - headers: {}, + headers: options.headers || {}, cookies: {} } as NextApiRequest; } @@ -591,4 +592,55 @@ describe('Validator Unit Tests', () => { expect(result.isValid).toBe(true); }); }); -}); \ No newline at end of file + + describe('Parameter location handling', () => { + it('should honor parameterLocation for path, query, header, and body', () => { + const manifestWithLocations = { + ...mockManifest, + capabilities: { + split_params: { + id: 'split_params', + v: 1, + description: 'Split params by location', + parameters: { + type: 'object', + required: ['id', 'q', 'token', 'content'], + properties: { + id: { type: 'string', pattern: '^[0-9]+$' }, + q: { type: 'string', minLength: 3 }, + token: { type: 'string', pattern: '^tok_' }, + content: { type: 'string', minLength: 5 } + } + }, + action: { + type: 'HTTP', + method: 'POST', + urlTemplate: '/api/posts/{id}{?q}', + cors: true, + encoding: 'json', + parameterLocation: { + id: 'path', + q: 'query', + token: 'header', + content: 'body' + } + } + } + } + }; + + mockFs.readFileSync.mockReturnValue(JSON.stringify(manifestWithLocations)); + + const req = createMockRequest({ + method: 'POST', + query: { id: '123', q: 'search', content: 'no' }, + body: { id: 'bad', q: '', content: 'hello world' }, + headers: { token: 'tok_abc' } + }); + + const result = validateRequest(req, 'split_params'); + + expect(result).toEqual({ isValid: true }); + }); + }); +}); diff --git a/packages/reference-server/lib/validator.ts b/packages/reference-server/lib/validator.ts index 7544ebe..f70d535 100644 --- a/packages/reference-server/lib/validator.ts +++ b/packages/reference-server/lib/validator.ts @@ -150,6 +150,143 @@ function convertParameterTypes(params: any, schema: any): any { return converted; } +const TEMPLATE_OPERATORS = '+#./;?&'; + +function extractTemplateVariables(template?: string): { path: Set; query: Set } { + const path = new Set(); + const query = new Set(); + + if (!template) { + return { path, query }; + } + + const matches = template.matchAll(/\{([^}]+)\}/g); + for (const match of matches) { + const expression = match[1]?.trim(); + if (!expression) continue; + + const operator = TEMPLATE_OPERATORS.includes(expression[0]) ? expression[0] : ''; + const varList = operator ? expression.slice(1) : expression; + + for (const rawVar of varList.split(',')) { + const trimmed = rawVar.trim(); + if (!trimmed) continue; + const withoutExplode = trimmed.replace(/\*$/, ''); + const name = withoutExplode.split(':')[0]; + if (!name) continue; + + if (operator === '?' || operator === '&') { + query.add(name); + } else { + path.add(name); + } + } + } + + return { path, query }; +} + +function normalizeHeaderValue(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +function pickParams(source: any, allowedKeys?: Set): Record { + if (!source || typeof source !== 'object') { + return {}; + } + + if (!allowedKeys) { + return { ...source }; + } + + const picked: Record = {}; + for (const key of allowedKeys) { + if (source[key] !== undefined) { + picked[key] = source[key]; + } + } + + return picked; +} + +function pickHeaderParams(headers: Record, allowedKeys?: Set): Record { + const picked: Record = {}; + if (!headers) return picked; + + if (!allowedKeys) { + return picked; + } + + for (const key of allowedKeys) { + const headerValue = normalizeHeaderValue(headers[key.toLowerCase()]); + if (headerValue !== undefined) { + picked[key] = headerValue; + } + } + + return picked; +} + +function splitQueryParamsByTemplate( + query: Record, + urlTemplate?: string, + allowedKeys?: Set +): { pathParams: Record; queryParams: Record } { + const { path: pathVars, query: queryVars } = extractTemplateVariables(urlTemplate); + const pathParams: Record = {}; + const queryParams: Record = {}; + + for (const [key, value] of Object.entries(query || {})) { + if (allowedKeys && !allowedKeys.has(key)) { + continue; + } + + if (pathVars.has(key) && !queryVars.has(key)) { + pathParams[key] = value; + continue; + } + + if (queryVars.has(key)) { + queryParams[key] = value; + continue; + } + + if (pathVars.has(key)) { + pathParams[key] = value; + continue; + } + + queryParams[key] = value; + } + + return { pathParams, queryParams }; +} + +function extractWithPrecedence( + req: NextApiRequest, + capability: any, + allowedKeys?: Set +): Record { + const { pathParams, queryParams } = splitQueryParamsByTemplate( + (req.query || {}) as Record, + capability?.action?.urlTemplate, + allowedKeys + ); + + const bodyParams = pickParams(req.body, allowedKeys); + const headerParams = pickHeaderParams(req.headers as Record, allowedKeys); + + return { + ...bodyParams, + ...headerParams, + ...queryParams, + ...pathParams + }; +} + /** * Extract parameters from request based on HTTP method and encoding * This function creates a unified parameters object by: @@ -158,6 +295,51 @@ function convertParameterTypes(params: any, schema: any): any { * 3. Ignoring req.body for GET requests (adheres to web standards) */ function extractRequestParameters(req: NextApiRequest, capability: any): any { + const hasParameterLocation = !!(capability?.action?.parameterLocation && Object.keys(capability.action.parameterLocation).length > 0); + const schemaKeys = capability?.parameters?.properties + ? new Set(Object.keys(capability.parameters.properties)) + : undefined; + + if (hasParameterLocation) { + const parameters: Record = {}; + const locationMap = capability.action.parameterLocation as Record; + + for (const [paramName, location] of Object.entries(locationMap)) { + let value: any = undefined; + + switch (location) { + case 'path': + case 'query': + value = (req.query as Record)[paramName]; + break; + case 'header': + value = normalizeHeaderValue((req.headers as Record)[paramName.toLowerCase()]); + break; + case 'body': + if (req.body && typeof req.body === 'object') { + value = (req.body as Record)[paramName]; + } + break; + default: + value = undefined; + break; + } + + if (value !== undefined) { + parameters[paramName] = value; + } + } + + const fallbackParams = extractWithPrecedence(req, capability, schemaKeys); + for (const [key, value] of Object.entries(fallbackParams)) { + if (parameters[key] === undefined) { + parameters[key] = value; + } + } + + return convertParameterTypes(parameters, capability.parameters); + } + // Create a new, unified parameters object const parameters: any = {}; @@ -303,4 +485,4 @@ export function sendValidationError( export function clearValidationCache() { validationCache.clear(); manifestCache = null; -} \ No newline at end of file +} diff --git a/packages/reference-server/public/.well-known/aura.json b/packages/reference-server/public/.well-known/aura.json index a1a1941..4cb09de 100644 --- a/packages/reference-server/public/.well-known/aura.json +++ b/packages/reference-server/public/.well-known/aura.json @@ -13,39 +13,57 @@ "uriPattern": "/api/posts/{id}", "description": "Blog post resource", "operations": { - "GET": { "capabilityId": "read_post" }, - "PUT": { "capabilityId": "update_post" }, - "DELETE": { "capabilityId": "delete_post" } + "GET": { + "capabilityId": "read_post" + }, + "PUT": { + "capabilityId": "update_post" + }, + "DELETE": { + "capabilityId": "delete_post" + } } }, "posts_collection": { "uriPattern": "/api/posts", "description": "Collection of blog posts", "operations": { - "GET": { "capabilityId": "list_posts" }, - "POST": { "capabilityId": "create_post" } + "GET": { + "capabilityId": "list_posts" + }, + "POST": { + "capabilityId": "create_post" + } } }, "auth_login": { "uriPattern": "/api/auth/login", "description": "Authentication login endpoint", "operations": { - "POST": { "capabilityId": "login" } + "POST": { + "capabilityId": "login" + } } }, "auth_logout": { "uriPattern": "/api/auth/logout", "description": "Authentication logout endpoint", "operations": { - "POST": { "capabilityId": "logout" } + "POST": { + "capabilityId": "logout" + } } }, "user_profile": { "uriPattern": "/api/user/profile", "description": "User profile endpoint", "operations": { - "GET": { "capabilityId": "get_profile" }, - "PUT": { "capabilityId": "update_profile" } + "GET": { + "capabilityId": "get_profile" + }, + "PUT": { + "capabilityId": "update_profile" + } } } }, @@ -95,7 +113,10 @@ "description": "Create a new blog post", "parameters": { "type": "object", - "required": ["title", "content"], + "required": [ + "title", + "content" + ], "properties": { "title": { "type": "string", @@ -138,7 +159,9 @@ "description": "Read a specific blog post", "parameters": { "type": "object", - "required": ["id"], + "required": [ + "id" + ], "properties": { "id": { "type": "string", @@ -151,7 +174,6 @@ "method": "GET", "urlTemplate": "/api/posts/{id}", "cors": true, - "encoding": "query", "parameterMapping": { "id": "/id" } @@ -163,7 +185,9 @@ "description": "Update an existing blog post", "parameters": { "type": "object", - "required": ["id"], + "required": [ + "id" + ], "properties": { "id": { "type": "string", @@ -210,7 +234,9 @@ "description": "Delete a blog post", "parameters": { "type": "object", - "required": ["id"], + "required": [ + "id" + ], "properties": { "id": { "type": "string", @@ -223,7 +249,6 @@ "method": "DELETE", "urlTemplate": "/api/posts/{id}", "cors": true, - "encoding": "query", "parameterMapping": { "id": "/id" } @@ -235,7 +260,10 @@ "description": "Authenticate user with email and password", "parameters": { "type": "object", - "required": ["email", "password"], + "required": [ + "email", + "password" + ], "properties": { "email": { "type": "string", @@ -325,4 +353,4 @@ }, "authHint": "cookie" } -} \ No newline at end of file +} \ No newline at end of file