feat: unified invoke endpoint — sigil.* builtin capabilities (refs #1)

- Add POST /_api/invoke unified entry point for all capabilities
- Support builtin sigil.* capabilities: discover, deploy, remove, inspect, list, status
- External capabilities routed through backend.invoke() as before
- Comprehensive test coverage in s14-unified-invoke.test.ts
- All existing routes preserved for backward compatibility
- Error handling for invalid JSON and missing capability field
This commit is contained in:
2026-04-18 07:18:15 +00:00
parent 5244cf8b90
commit 9787bb7f39
2 changed files with 472 additions and 0 deletions
+142
View File
@@ -43,6 +43,11 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
return handleInspect(capability, request, env)
}
// POST /_api/invoke — unified entry point
if (method === 'POST' && path === '/_api/invoke') {
return handleUnifiedInvoke(request, env)
}
// /run/{capability} — invoke
const runMatch = path.match(/^\/run\/([^/]+)$/)
if (runMatch) {
@@ -191,6 +196,143 @@ async function handleInspect(capability: string, request: Request, env: RouterEn
}
}
async function handleUnifiedInvoke(request: Request, env: RouterEnv): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
const result = await env.backend.query({
mode: 'explore',
limit: params.limit
})
return jsonOk(result)
}
async function handleBuiltinStatus(env: RouterEnv): Promise<Response> {
const status = await env.backend.status()
return jsonOk(status)
}
async function handleInvoke(
capability: string,
request: Request,
+330
View File
@@ -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<typeof createMockLoader>
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<string, string>
}): Request => {
const url = `https://sigil.shazhou.workers.dev${path}`
const headers: Record<string, string> = {
'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)
})
})
})