diff --git a/src/router.ts b/src/router.ts index 5e426e2..879cc45 100644 --- a/src/router.ts +++ b/src/router.ts @@ -43,6 +43,11 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise { + try { + const authHeader = request.headers.get('Authorization') + await env.auth.validateToken(authHeader) + + let body: { capability: string, params?: any } + try { + body = await request.json() as { capability: string, params?: any } + } catch (e) { + return jsonError(400, 'Invalid JSON in request body') + } + + const { capability, params = {} } = body + + if (!capability) { + return jsonError(400, 'capability field is required') + } + + // Handle builtin capabilities + if (capability.startsWith('sigil.')) { + return handleBuiltinCapability(capability, params, request, env) + } + + // Handle external capabilities + const wrappedRequest = new Request(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(params) + }) + return await env.backend.invoke(capability, wrappedRequest) + } catch (e) { + if (e instanceof AuthError) { + return jsonError(e.status, e.message) + } + throw e + } +} + +async function handleBuiltinCapability( + capability: string, + params: any, + request: Request, + env: RouterEnv +): Promise { + switch (capability) { + case 'sigil.discover': + return handleBuiltinQuery(params, env) + case 'sigil.deploy': + return handleBuiltinDeploy(params, env) + case 'sigil.remove': + return handleBuiltinRemove(params, env) + case 'sigil.inspect': + return handleBuiltinInspect(params, request, env) + case 'sigil.list': + return handleBuiltinList(params, env) + case 'sigil.status': + return handleBuiltinStatus(env) + default: + return jsonError(404, `Builtin capability not found: ${capability}`) + } +} + +async function handleBuiltinQuery(params: any, env: RouterEnv): Promise { + const result = await env.backend.query({ + q: params.q, + mode: params.mode, + limit: params.limit, + cursor: params.cursor + }) + return jsonOk(result) +} + +async function handleBuiltinDeploy(params: any, env: RouterEnv): Promise { + try { + // Check deploy cooldown + await env.auth.checkDeployCooldown() + + const result = await env.backend.deploy({ + name: params.name, + code: params.code, + execute: params.execute, + schema: params.schema, + type: params.type, + ttl: params.ttl, + bindings: params.bindings, + description: params.description, + tags: params.tags, + examples: params.examples, + requires: params.requires, + }) + + // Set cooldown after successful deploy + await env.auth.setDeployCooldown() + + return jsonOk(result, 201) + } catch (e) { + if (e instanceof DeployCooldownError) { + return jsonError(429, 'Deploy cooldown active', { retry_after: e.retry_after }) + } + throw e + } +} + +async function handleBuiltinRemove(params: any, env: RouterEnv): Promise { + if (!params.capability) { + return jsonError(400, 'capability field is required') + } + + await env.backend.remove(params.capability) + return jsonOk({ removed: params.capability }) +} + +async function handleBuiltinInspect(params: any, request: Request, env: RouterEnv): Promise { + if (!params.capability) { + return jsonError(400, 'capability field is required') + } + + const result = await env.backend.inspect(params.capability) + if (!result) { + return jsonError(404, 'Capability not found') + } + return jsonOk(result) +} + +async function handleBuiltinList(params: any, env: RouterEnv): Promise { + const result = await env.backend.query({ + mode: 'explore', + limit: params.limit + }) + return jsonOk(result) +} + +async function handleBuiltinStatus(env: RouterEnv): Promise { + const status = await env.backend.status() + return jsonOk(status) +} + async function handleInvoke( capability: string, request: Request, diff --git a/test/s14-unified-invoke.test.ts b/test/s14-unified-invoke.test.ts new file mode 100644 index 0000000..173df9f --- /dev/null +++ b/test/s14-unified-invoke.test.ts @@ -0,0 +1,330 @@ +import { describe, test, expect, beforeEach } from 'vitest' +import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js' +import { WorkerPool } from '../src/backend/worker-pool.js' +import { AuthModule } from '../src/auth.js' +import { KvStore } from '../src/kv.js' +import { handleRequest } from '../src/router.js' + +describe('Unified Invoke API', () => { + let mockKv: KVNamespace + let mockLoader: ReturnType + let mockEmbed: MockEmbeddingService + let pool: WorkerPool + let auth: AuthModule + let kv: KvStore + + beforeEach(async () => { + mockKv = createMockKv() + mockLoader = createMockLoader({ + invokeResponse: (workerId, request) => { + // Mock external capability responses + return new Response(JSON.stringify({ result: 'external success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + } + }) + mockEmbed = new MockEmbeddingService() + pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any) + kv = new KvStore(mockKv) + auth = new AuthModule(kv) + + // Set unified deploy token + await auth.setToken('valid-token') + }) + + const makeRequest = (method: string, path: string, options?: { + body?: unknown + token?: string + headers?: Record + }): Request => { + const url = `https://sigil.shazhou.workers.dev${path}` + const headers: Record = { + 'Content-Type': 'application/json', + ...options?.headers, + } + + if (options?.token) { + headers['Authorization'] = `Bearer ${options.token}` + } + + const init: RequestInit = { + method, + headers, + } + + if (options?.body !== undefined) { + init.body = JSON.stringify(options.body) + } + + return new Request(url, init) + } + + // Test builtin capabilities + describe('Builtin Capabilities', () => { + test('sigil.discover - query capabilities', async () => { + // Deploy a test capability first + await pool.deploy({ + name: 'hello', + code: "export default { fetch() { return new Response('hello') } }", + type: 'normal' + }) + + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'sigil.discover', + params: { + q: 'hello', + limit: 5 + } + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(200) + + const result = await response.json() + expect(result.items).toBeInstanceOf(Array) + expect(result.total).toBeGreaterThan(0) + }) + + test('sigil.deploy - deploy capability', async () => { + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'sigil.deploy', + params: { + name: 'test-cap', + execute: 'return "hello world"', + type: 'normal' + } + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(201) + + const result = await response.json() + expect(result.capability).toBe('test-cap') + }) + + test('sigil.remove - remove capability', async () => { + // Deploy a capability first + await pool.deploy({ + name: 'test-cap', + code: "export default { fetch() { return new Response('test') } }", + type: 'normal' + }) + + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'sigil.remove', + params: { + capability: 'test-cap' + } + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(200) + + const result = await response.json() + expect(result.removed).toBe('test-cap') + }) + + test('sigil.inspect - inspect capability', async () => { + // Deploy a capability first + await pool.deploy({ + name: 'hello', + code: "export default { fetch() { return new Response('hello') } }", + type: 'normal' + }) + + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'sigil.inspect', + params: { + capability: 'hello' + } + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(200) + + const result = await response.json() + expect(result.capability).toBe('hello') + expect(result.type).toBe('normal') + }) + + test('sigil.list - list all capabilities', async () => { + // Deploy some test capabilities + await pool.deploy({ + name: 'cap1', + code: "export default { fetch() { return new Response('cap1') } }", + type: 'normal' + }) + await pool.deploy({ + name: 'cap2', + code: "export default { fetch() { return new Response('cap2') } }", + type: 'persistent' + }) + + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'sigil.list', + params: { + limit: 10 + } + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(200) + + const result = await response.json() + expect(result.items).toBeInstanceOf(Array) + expect(result.items.length).toBeGreaterThanOrEqual(2) + }) + + test('sigil.status - get system status', async () => { + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'sigil.status', + params: {} + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(200) + + const result = await response.json() + expect(result).toHaveProperty('backend') + expect(result).toHaveProperty('total_slots') + }) + + test('unknown builtin capability should return 404', async () => { + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'sigil.unknown', + params: {} + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(404) + + const result = await response.json() + expect(result.error).toContain('Builtin capability not found') + }) + }) + + // Test external capabilities + describe('External Capabilities', () => { + test('should invoke external capability', async () => { + // Deploy a capability first + await pool.deploy({ + name: 'external-cap', + code: "export default { fetch() { return new Response(JSON.stringify({result: 'external success'}), {headers: {'Content-Type': 'application/json'}}) } }", + type: 'normal' + }) + + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'external-cap', + params: { + input: 'test data' + } + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(200) + + const result = await response.json() + expect(result.result).toBe('external success') + }) + + test('external capability not found should return error', async () => { + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + capability: 'missing-cap', + params: {} + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBeGreaterThanOrEqual(400) + }) + }) + + // Test error cases + describe('Error Cases', () => { + test('missing capability field should return 400', async () => { + const request = makeRequest('POST', '/_api/invoke', { + token: 'valid-token', + body: { + params: { test: 'data' } + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(400) + + const result = await response.json() + expect(result.error).toBe('capability field is required') + }) + + test('missing auth should return 401', async () => { + const request = makeRequest('POST', '/_api/invoke', { + body: { + capability: 'sigil.status', + params: {} + } + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(401) + }) + + test('invalid JSON should return 400', async () => { + const request = new Request('https://sigil.shazhou.workers.dev/_api/invoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer valid-token' + }, + body: 'invalid json' + }) + + const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(response.status).toBe(400) + + const result = await response.json() + expect(result.error).toBe('Invalid JSON in request body') + }) + }) + + // Test compatibility with existing routes + describe('Backward Compatibility', () => { + test('existing routes should still work', async () => { + // Test old health route + const healthRequest = makeRequest('GET', '/_health') + const healthResponse = await handleRequest(healthRequest, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(healthResponse.status).toBe(200) + + // Test old query route + const queryRequest = makeRequest('GET', '/_api/query?q=test', { token: 'valid-token' }) + const queryResponse = await handleRequest(queryRequest, { SIGIL_KV: mockKv, backend: pool, auth, kv }) + expect(queryResponse.status).toBe(200) + }) + }) +}) \ No newline at end of file