Copy architecture docs, ADRs, storage domain specs, research, reviews, and 56 storage architecture tasks from the alkhub_ts monorepo. Adapt for standalone @alkdev/hub repo structure (src/ not packages/hub/). Sanitize all sensitive information: - Replace private IPs (10.0.0.1) with localhost defaults - Remove internal server hostnames (dev1, ns528096) - Replace /workspace/ private paths with npm package references - Remove hardcoded credentials from examples - Rewrite infrastructure.md without private network details Add Deno project scaffolding: deno.json (pinned deps), .gitignore, AGENTS.md, entry point. Migrate existing code stubs (crypto, config types, logger) with updated import paths.
192 lines
9.2 KiB
Markdown
192 lines
9.2 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-05-18
|
|
---
|
|
|
|
# Operations System
|
|
|
|
## Overview
|
|
|
|
The operations system is the universal abstraction for all work in the alk.dev platform. Every API endpoint, agent action, coordination tool, and MCP tool is an operation with typed input/output schemas, access control metadata, and a handler function.
|
|
|
|
**Package**: `@alkdev/operations` (npm)
|
|
|
|
## Core Components
|
|
|
|
### Core Types (`operations/types.ts`)
|
|
|
|
- `OperationType` — QUERY (read-only), MUTATION (write), SUBSCRIPTION (async generator)
|
|
- `OperationSpec` — serializable, hashable subset (name, namespace, version, type, description, tags, inputSchema, outputSchema, errorSchemas, accessControl, \_meta)
|
|
- `IOperationDefinition` — extends `OperationSpec` with runtime `handler`
|
|
- `OperationContext` — metadata, requestId, parentRequestId, identity, env
|
|
- `AccessControl` — requiredScopes (all match), requiredScopesAny (any match), resourceType, resourceAction. See below.
|
|
- `ResponseEnvelope<T>` — universal result wrapper with source tracking (local/http/mcp). All `execute()` and `env` functions return `ResponseEnvelope<T>`.
|
|
- `CallError` / `InfrastructureErrorCode` — structured error codes: `OPERATION_NOT_FOUND`, `ACCESS_DENIED`, `VALIDATION_ERROR`, `TIMEOUT`, `ABORTED`, `EXECUTION_ERROR`, `UNKNOWN_ERROR`.
|
|
|
|
### Registry (`operations/registry.ts`)
|
|
|
|
- Register by `{namespace}.{name}` key
|
|
- `register()` now accepts `OperationSpec & { handler? }` (handler can be registered separately)
|
|
- `registerSpec()` / `registerHandler()` — separate spec and handler registration
|
|
- `execute()` returns `Promise<ResponseEnvelope<TOutput>>` (not `Promise<TOutput>`)
|
|
- Constructor accepts optional `SchemaAdapter` for Zod/Valibot conversion
|
|
- Access control is enforced in the registry (via `enforceAccess`)
|
|
- Validate input before handler execution
|
|
- Warn on output schema mismatch (don't throw)
|
|
- `getSpec()` / `getAllSpecs()` for serializable specs
|
|
|
|
### Scanner (`operations/scanner.ts`)
|
|
|
|
- Recursive filesystem scan for `.ts` operation definitions
|
|
- `scanOperations(dirPath, fs)` — takes an abstracted `ScannerFS` interface, not `Deno.readDir` directly
|
|
- `ScannerFS { readdir(path): AsyncIterable, cwd(): string }` — inject Deno or Node adapter
|
|
- Auto-discovery and registration
|
|
- Validates against `OperationSpecSchema`, not `OperationDefinition`
|
|
|
|
### Env Builder (`operations/env.ts`)
|
|
|
|
- `buildEnv()` creates namespace-keyed `OperationEnv` for nested calls
|
|
- Direct mode: `buildEnv({ registry, context })` → env functions call `registry.execute()` directly
|
|
- `buildEnv` no longer takes a `callMap` parameter
|
|
- Sets `trusted: true` on nested context (bypasses access control for internal calls)
|
|
- Env functions return `Promise<ResponseEnvelope>`, callers use `unwrap(envelope)` or `envelope.data`
|
|
- Filters SUBSCRIPTION operations out of env
|
|
|
|
### FromSchema (`operations/from_schema.ts`)
|
|
|
|
- JSON Schema → TypeBox `TSchema` converter
|
|
- Handles allOf, anyOf, oneOf, enum, object, tuple, array, const, $ref, primitives
|
|
|
|
### Schema Adapters (`@alkdev/operations/from-typemap`)
|
|
|
|
The `SchemaAdapter` pattern converts non-TypeBox schemas to TypeBox at registration time:
|
|
|
|
```ts
|
|
import { zodAdapter, valibotAdapter } from "@alkdev/operations/from-typemap"
|
|
|
|
const registry = new OperationRegistry({ schemaAdapter: zodAdapter() })
|
|
// or: { schemaAdapter: valibotAdapter() }
|
|
// or: { schemaAdapter: defaultAdapter } // TypeBox only (default)
|
|
```
|
|
|
|
The `SchemaAdapter` interface has `toTypeBox(schema)` and optional `init()`. Zod and Valibot adapters use dynamic import of `@alkdev/typemap` and check for `~standard` vendor property for auto-detection.
|
|
|
|
`@alkdev/typemap` is an optional peer dependency — it's only loaded when a Zod or Valibot schema is actually encountered. Spoke authors using TypeBox directly have no extra dependencies. Non-TypeScript spokes send JSON Schema over the wire, which the hub converts via `FromSchema()`.
|
|
|
|
**See ADR-013** for the full decision and trade-offs.
|
|
|
|
### FromOpenAPI (`operations/from_openapi.ts`)
|
|
|
|
- **Key piece**: generates `IOperationDefinition[]` from OpenAPI specs
|
|
- Detects `text/event-stream` responses as SUBSCRIPTION type
|
|
- Auto-generates HTTP fetch handlers with path/query/body param routing
|
|
- Supports bearer, apiKey, basic auth
|
|
- **Use case**: import opencode's OpenAPI spec → instant typed client operations
|
|
|
|
### MCP Wrapper (`mcp/wrapper.ts`, `mcp/loader.ts`)
|
|
|
|
- `createMCPClient` connects to MCP servers (stdio or HTTP)
|
|
- MCP tools → `IOperationDefinition[]` with auto-generated handlers
|
|
- `MCPClientLoader` manages multiple MCP client connections
|
|
- **Use case**: connect to external MCP servers (websearch, etc.) and wrap as operations
|
|
|
|
### ResponseEnvelope
|
|
|
|
All `execute()` calls and `env` functions return `ResponseEnvelope<T>`:
|
|
|
|
```ts
|
|
interface ResponseEnvelope<T> {
|
|
data: T
|
|
meta: ResponseMeta // source: "local" | "http" | "mcp", timestamps, status codes
|
|
}
|
|
```
|
|
|
|
Factory functions: `localEnvelope(data, operationId)`, `httpEnvelope(data, meta)`, `mcpEnvelope(data, meta)`. Use `unwrap(envelope)` to extract `.data` or `isResponseEnvelope(value)` to type-guard.
|
|
|
|
### Access Control
|
|
|
|
`checkAccess(accessControl, identity)` — boolean check. `enforceAccess(accessControl, identity, operationId, trusted?)` — throws `CallError` on denial. The `trusted: true` flag bypasses all access checks (set by `buildEnv` on nested calls).
|
|
|
|
### CallError
|
|
|
|
`CallError` extends `Error` with `code` and `details`. `InfrastructureErrorCode` enum provides standard error codes. `mapError(error, errorSchemas?)` matches thrown errors against declared `errorSchemas`.
|
|
|
|
## Open Issues
|
|
|
|
### Call Protocol Integration
|
|
|
|
Operations use `buildEnv()` which supports direct mode (see call-graph.md):
|
|
|
|
- **Direct mode**: `buildEnv({ registry, context })` → env functions call `registry.execute()`
|
|
|
|
The call protocol (PendingRequestMap, CallHandler) is part of `@alkdev/operations`. It provides call graph tracking, abort cascading, and structured error handling across all transports. See call-graph.md for the full spec.
|
|
|
|
## How It Connects to Everything Else
|
|
|
|
```
|
|
Hub HTTP API routes ──→ registry.execute("namespace.operation", input, ctx)
|
|
│
|
|
MCP server tools ──→ registry.execute(...)
|
|
│
|
|
FromOpenAPI ops ──→ fetch(opencode container REST API)
|
|
│
|
|
MCP client tools ──→ MCPClientLoader → registry.execute(...)
|
|
│
|
|
Agent session LLM ──→ tool calls with JSON Schema → registry.execute(...)
|
|
```
|
|
|
|
All paths funnel into the same registry. Access control, validation, and error handling are consistent regardless of entry point.
|
|
|
|
## Access Control Model
|
|
|
|
Authentication uses [keypal](https://npmjs.com/package/keypal) for API key management. keypal verifies bearer tokens and provides a two-tier scope model:
|
|
|
|
1. **Global scopes**: flat string array (e.g., `["read", "write", "admin"]`)
|
|
2. **Resource-scoped permissions**: `Record<string, string[]>` keyed by `"type:id"` (e.g., `{ "project:abc": ["read", "write"] }`)
|
|
|
|
### Identity
|
|
|
|
The `Identity` type derives from keypal's `ApiKeyMetadata`:
|
|
|
|
```ts
|
|
interface Identity {
|
|
id: string // keypal ownerId
|
|
scopes: string[] // global scopes from keypal
|
|
resources?: Record<string, string[]> // resource-scoped permissions, key format: "type:id"
|
|
}
|
|
```
|
|
|
|
"Roles" are scope bundles — a convention on top of scopes, not a separate type. For example, a scope of `"implement"` might grant access to `["dev.fs.read", "dev.fs.write", "dev.bash.exec"]`. Defining which scopes a "role" maps to is a configuration concern, not a type-system concern.
|
|
|
|
### AccessControl
|
|
|
|
The `AccessControl` definition on each operation declares what permissions are required:
|
|
|
|
| Field | Semantics | Example |
|
|
|-------|-----------|---------|
|
|
| `requiredScopes` | AND — caller must have ALL of these scopes | `["call"]` — caller can invoke operations |
|
|
| `requiredScopesAny` | OR — caller must have at least ONE of these scopes | `["admin", "coord.spawn"]` — admin OR can spawn |
|
|
| `resourceType` | Resource category for resource-scoped checks | `"project"` |
|
|
| `resourceAction` | Required action on the resource | `"write"` |
|
|
|
|
**Enforcement**: The `CallHandler` (see call-graph.md) checks `AccessControl` against `Identity` before dispatching to `registry.execute()`. The registry itself is a pure execution engine — access control lives at the call handler layer.
|
|
|
|
**Resource checks**: When `resourceType` + `resourceAction` are set, the check is: does `identity.resources["{resourceType}:{resourceId}"]` include `resourceAction`? This maps directly to keypal's `checkResourceScope(record, resourceType, resourceId, scope)`.
|
|
|
|
### Access Control Flow
|
|
|
|
```
|
|
Request → CallHandler receives call.requested with Identity
|
|
→ Look up operation's AccessControl
|
|
→ Check requiredScopes (caller has ALL?)
|
|
→ Check requiredScopesAny (caller has ANY?)
|
|
→ Check resourceType/resourceAction against identity.resources
|
|
→ All pass → registry.execute()
|
|
→ Any fail → call.error with ACCESS_DENIED
|
|
```
|
|
|
|
## Known Gaps
|
|
|
|
- **Logger config**: `core/logger/mod.ts` is a stub that only logs the `["logtape", "meta"]` category. Needs proper config for app-level loggers.
|
|
- **Config**: `core/config/types.ts` has spoke-only config. Needs hub-specific config (postgres, redis, auth).
|