@openverb/runtime 2.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/engine.ts +166 -0
- package/src/errors.ts +31 -0
- package/src/index.ts +18 -0
- package/src/registry.ts +34 -0
- package/src/types.ts +128 -0
- package/src/validator.ts +26 -0
- package/tsconfig.json +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openverb/runtime",
|
|
3
|
+
"version": "2.0.0-alpha.1",
|
|
4
|
+
"description": "OpenVerb execution runtime",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"import": "./dist/index.mjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
17
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"test": "vitest run"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ajv": "^8.12.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^20.10.0",
|
|
26
|
+
"tsup": "^8.0.1",
|
|
27
|
+
"typescript": "^5.3.2",
|
|
28
|
+
"vitest": "^1.0.4"
|
|
29
|
+
},
|
|
30
|
+
"keywords": ["openverb", "runtime", "execution"],
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { loadVerbs, getVerbById } from './registry'
|
|
3
|
+
import { validateInput, validateOutput } from './validator'
|
|
4
|
+
import { VerbNotFoundError, PolicyDeniedError, HandlerError } from './errors'
|
|
5
|
+
import type {
|
|
6
|
+
Runtime,
|
|
7
|
+
ExecuteRequest,
|
|
8
|
+
ExecuteResponse,
|
|
9
|
+
VerbDefinition,
|
|
10
|
+
PolicyEngine,
|
|
11
|
+
Handler,
|
|
12
|
+
Event,
|
|
13
|
+
Receipt,
|
|
14
|
+
HandlerContext
|
|
15
|
+
} from './types'
|
|
16
|
+
|
|
17
|
+
type RuntimeConfig = {
|
|
18
|
+
verbs?: VerbDefinition[]
|
|
19
|
+
verbsDir?: string
|
|
20
|
+
policy: PolicyEngine
|
|
21
|
+
handlers: Record<string, Handler>
|
|
22
|
+
adapters?: Record<string, any>
|
|
23
|
+
onEvent?: (event: Event) => void | Promise<void>
|
|
24
|
+
onReceipt?: (receipt: Receipt) => void | Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createRuntime(config: RuntimeConfig): Runtime {
|
|
28
|
+
const verbs = config.verbs || loadVerbs(config.verbsDir)
|
|
29
|
+
const events: Event[] = []
|
|
30
|
+
const executionId = crypto.randomUUID()
|
|
31
|
+
|
|
32
|
+
async function execute(req: ExecuteRequest): Promise<ExecuteResponse> {
|
|
33
|
+
const startTime = Date.now()
|
|
34
|
+
|
|
35
|
+
// Stage 1: Resolve verb
|
|
36
|
+
const verb = getVerbById(req.verbId, verbs)
|
|
37
|
+
if (!verb) {
|
|
38
|
+
const receipt = createReceipt(req, 'error', startTime)
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: `Verb not found: ${req.verbId}`,
|
|
42
|
+
receipt,
|
|
43
|
+
events: []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Stage 2: Validate input
|
|
49
|
+
validateInput(verb.inputSchema, req.args)
|
|
50
|
+
|
|
51
|
+
// Stage 3: Policy check
|
|
52
|
+
const decision = await config.policy(verb, req.actor, req.context)
|
|
53
|
+
|
|
54
|
+
emitEvent({
|
|
55
|
+
type: decision.decision === 'allow' ? 'policy.allowed' : 'policy.denied',
|
|
56
|
+
data: { verbId: verb.id, decision }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (decision.decision === 'deny') {
|
|
60
|
+
const receipt = createReceipt(req, 'denied', startTime)
|
|
61
|
+
await config.onReceipt?.(receipt)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
denied: true,
|
|
66
|
+
reason: { code: decision.code, message: decision.message },
|
|
67
|
+
upsell: decision.upsell,
|
|
68
|
+
receipt,
|
|
69
|
+
events
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Stage 4: Execute handler
|
|
74
|
+
const handler = config.handlers[verb.handler]
|
|
75
|
+
if (!handler) {
|
|
76
|
+
throw new HandlerError(`Handler not found: ${verb.handler}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ctx: HandlerContext = {
|
|
80
|
+
actor: req.actor,
|
|
81
|
+
context: req.context,
|
|
82
|
+
adapters: config.adapters || {},
|
|
83
|
+
logger: {
|
|
84
|
+
info: (msg, meta) => console.log(`[INFO] ${msg}`, meta),
|
|
85
|
+
error: (msg, meta) => console.error(`[ERROR] ${msg}`, meta)
|
|
86
|
+
},
|
|
87
|
+
emitEvent
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await handler(ctx, req.args)
|
|
91
|
+
|
|
92
|
+
// Stage 5: Validate output
|
|
93
|
+
validateOutput(verb.outputSchema, result)
|
|
94
|
+
|
|
95
|
+
// Stage 6: Create receipt
|
|
96
|
+
const receipt = createReceipt(req, 'ok', startTime)
|
|
97
|
+
await config.onReceipt?.(receipt)
|
|
98
|
+
|
|
99
|
+
// Stage 7: Return response
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
result,
|
|
103
|
+
receipt,
|
|
104
|
+
events
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
} catch (error) {
|
|
108
|
+
emitEvent({
|
|
109
|
+
type: 'handler.error',
|
|
110
|
+
data: { error: error instanceof Error ? error.message : 'Unknown error' }
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const receipt = createReceipt(req, 'error', startTime)
|
|
114
|
+
await config.onReceipt?.(receipt)
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
119
|
+
receipt,
|
|
120
|
+
events
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function emitEvent(event: Omit<Event, 'timestamp' | 'executionId'>) {
|
|
126
|
+
const fullEvent: Event = {
|
|
127
|
+
...event,
|
|
128
|
+
timestamp: new Date().toISOString(),
|
|
129
|
+
executionId
|
|
130
|
+
}
|
|
131
|
+
events.push(fullEvent)
|
|
132
|
+
config.onEvent?.(fullEvent)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createReceipt(
|
|
136
|
+
req: ExecuteRequest,
|
|
137
|
+
status: 'ok' | 'denied' | 'error',
|
|
138
|
+
startTime: number
|
|
139
|
+
): Receipt {
|
|
140
|
+
return {
|
|
141
|
+
executionId,
|
|
142
|
+
verbId: req.verbId,
|
|
143
|
+
verbVersion: getVerbById(req.verbId, verbs)?.version || '0.0.0',
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
actorId: req.actor.id,
|
|
146
|
+
tenantId: req.context.tenantId,
|
|
147
|
+
status,
|
|
148
|
+
durationMs: Date.now() - startTime
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function introspect(): VerbDefinition[] {
|
|
153
|
+
return verbs.map(v => ({
|
|
154
|
+
id: v.id,
|
|
155
|
+
version: v.version,
|
|
156
|
+
summary: v.summary,
|
|
157
|
+
inputSchema: v.inputSchema,
|
|
158
|
+
outputSchema: v.outputSchema,
|
|
159
|
+
effects: v.effects,
|
|
160
|
+
visibility: v.visibility,
|
|
161
|
+
handler: v.handler
|
|
162
|
+
}))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { execute, introspect }
|
|
166
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class VerbNotFoundError extends Error {
|
|
2
|
+
constructor(verbId: string) {
|
|
3
|
+
super(`Verb not found: ${verbId}`)
|
|
4
|
+
this.name = 'VerbNotFoundError'
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ValidationError extends Error {
|
|
9
|
+
constructor(message: string, public errors: any[]) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'ValidationError'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class PolicyDeniedError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
public code: string,
|
|
19
|
+
public upsell?: { suggestedPlanId: string; cta: string }
|
|
20
|
+
) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'PolicyDeniedError'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class HandlerError extends Error {
|
|
27
|
+
constructor(message: string, public cause?: Error) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = 'HandlerError'
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @openverb/runtime - Main entry point
|
|
2
|
+
export { createRuntime } from './engine'
|
|
3
|
+
export { loadVerbs, getVerbById } from './registry'
|
|
4
|
+
export { validateInput, validateOutput } from './validator'
|
|
5
|
+
export type {
|
|
6
|
+
Runtime,
|
|
7
|
+
ExecuteRequest,
|
|
8
|
+
ExecuteResponse,
|
|
9
|
+
VerbDefinition,
|
|
10
|
+
HandlerContext,
|
|
11
|
+
Handler,
|
|
12
|
+
Actor,
|
|
13
|
+
Context,
|
|
14
|
+
Receipt,
|
|
15
|
+
Event,
|
|
16
|
+
PolicyDecision
|
|
17
|
+
} from './types'
|
|
18
|
+
export { VerbNotFoundError, ValidationError, PolicyDeniedError, HandlerError } from './errors'
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import type { VerbDefinition } from './types'
|
|
4
|
+
|
|
5
|
+
let cachedVerbs: VerbDefinition[] | null = null
|
|
6
|
+
|
|
7
|
+
export function loadVerbs(verbsDir: string = './openverb/verbs'): VerbDefinition[] {
|
|
8
|
+
if (cachedVerbs) return cachedVerbs
|
|
9
|
+
|
|
10
|
+
const resolvedDir = path.resolve(process.cwd(), verbsDir)
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
13
|
+
console.warn(`Verbs directory not found: ${resolvedDir}`)
|
|
14
|
+
return []
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const files = fs.readdirSync(resolvedDir).filter(f => f.endsWith('.json'))
|
|
18
|
+
|
|
19
|
+
cachedVerbs = files.map(file => {
|
|
20
|
+
const content = fs.readFileSync(path.join(resolvedDir, file), 'utf-8')
|
|
21
|
+
return JSON.parse(content) as VerbDefinition
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return cachedVerbs
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getVerbById(verbId: string, verbs?: VerbDefinition[]): VerbDefinition | null {
|
|
28
|
+
const registry = verbs || loadVerbs()
|
|
29
|
+
return registry.find(v => v.id === verbId) || null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clearCache() {
|
|
33
|
+
cachedVerbs = null
|
|
34
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { JSONSchemaType } from 'ajv'
|
|
2
|
+
|
|
3
|
+
// Core types
|
|
4
|
+
export type Actor = {
|
|
5
|
+
type: 'user' | 'service' | 'ai' | 'system'
|
|
6
|
+
id: string
|
|
7
|
+
roles?: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type Context = {
|
|
11
|
+
tenantId: string
|
|
12
|
+
planId: string
|
|
13
|
+
env: 'dev' | 'staging' | 'prod'
|
|
14
|
+
requestId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type VerbDefinition = {
|
|
18
|
+
id: string
|
|
19
|
+
version: string
|
|
20
|
+
summary: string
|
|
21
|
+
description?: string
|
|
22
|
+
inputSchema: any // JSON Schema
|
|
23
|
+
outputSchema: any // JSON Schema
|
|
24
|
+
effects: string[]
|
|
25
|
+
visibility: 'public' | 'internal' | 'admin'
|
|
26
|
+
handler: string
|
|
27
|
+
policy?: {
|
|
28
|
+
rolesAllowed?: string[]
|
|
29
|
+
plansAllowed?: string[]
|
|
30
|
+
}
|
|
31
|
+
billing?: {
|
|
32
|
+
meterKey?: string
|
|
33
|
+
defaultDelta?: number
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PolicyDecision =
|
|
38
|
+
| {
|
|
39
|
+
decision: 'allow'
|
|
40
|
+
reasons: string[]
|
|
41
|
+
limits?: Record<string, any>
|
|
42
|
+
meter?: { key: string; delta: number }
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
decision: 'deny'
|
|
46
|
+
reasons: string[]
|
|
47
|
+
code: string
|
|
48
|
+
message: string
|
|
49
|
+
upsell?: {
|
|
50
|
+
suggestedPlanId: string
|
|
51
|
+
cta: string
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Event = {
|
|
56
|
+
type: string
|
|
57
|
+
timestamp: string
|
|
58
|
+
executionId: string
|
|
59
|
+
data: Record<string, unknown>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Receipt = {
|
|
63
|
+
executionId: string
|
|
64
|
+
verbId: string
|
|
65
|
+
verbVersion: string
|
|
66
|
+
timestamp: string
|
|
67
|
+
actorId: string
|
|
68
|
+
tenantId: string
|
|
69
|
+
status: 'ok' | 'denied' | 'error'
|
|
70
|
+
durationMs: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type HandlerContext = {
|
|
74
|
+
actor: Actor
|
|
75
|
+
context: Context
|
|
76
|
+
adapters: Record<string, any>
|
|
77
|
+
logger: {
|
|
78
|
+
info: (message: string, meta?: any) => void
|
|
79
|
+
error: (message: string, meta?: any) => void
|
|
80
|
+
}
|
|
81
|
+
emitEvent: (event: Omit<Event, 'timestamp' | 'executionId'>) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type Handler<TArgs = any, TResult = any> = (
|
|
85
|
+
ctx: HandlerContext,
|
|
86
|
+
args: TArgs
|
|
87
|
+
) => Promise<TResult>
|
|
88
|
+
|
|
89
|
+
export type ExecuteRequest = {
|
|
90
|
+
verbId: string
|
|
91
|
+
args: unknown
|
|
92
|
+
actor: Actor
|
|
93
|
+
context: Context
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ExecuteResponse =
|
|
97
|
+
| {
|
|
98
|
+
ok: true
|
|
99
|
+
result: unknown
|
|
100
|
+
receipt: Receipt
|
|
101
|
+
events: Event[]
|
|
102
|
+
uiHints?: Array<{ type: string; level: string; message: string }>
|
|
103
|
+
}
|
|
104
|
+
| {
|
|
105
|
+
ok: false
|
|
106
|
+
denied: true
|
|
107
|
+
reason: { code: string; message: string }
|
|
108
|
+
upsell?: { suggestedPlanId: string; cta: string }
|
|
109
|
+
receipt: Receipt
|
|
110
|
+
events: Event[]
|
|
111
|
+
}
|
|
112
|
+
| {
|
|
113
|
+
ok: false
|
|
114
|
+
error: string
|
|
115
|
+
receipt: Receipt
|
|
116
|
+
events: Event[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type PolicyEngine = (
|
|
120
|
+
verb: VerbDefinition,
|
|
121
|
+
actor: Actor,
|
|
122
|
+
context: Context
|
|
123
|
+
) => Promise<PolicyDecision>
|
|
124
|
+
|
|
125
|
+
export type Runtime = {
|
|
126
|
+
execute: (req: ExecuteRequest) => Promise<ExecuteResponse>
|
|
127
|
+
introspect: () => VerbDefinition[]
|
|
128
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Ajv from 'ajv'
|
|
2
|
+
import { ValidationError } from './errors'
|
|
3
|
+
|
|
4
|
+
const ajv = new Ajv({ allErrors: true })
|
|
5
|
+
|
|
6
|
+
export function validateInput(schema: any, data: unknown): void {
|
|
7
|
+
const validate = ajv.compile(schema)
|
|
8
|
+
const valid = validate(data)
|
|
9
|
+
|
|
10
|
+
if (!valid) {
|
|
11
|
+
throw new ValidationError(
|
|
12
|
+
'Input validation failed',
|
|
13
|
+
validate.errors || []
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateOutput(schema: any, data: unknown): void {
|
|
19
|
+
const validate = ajv.compile(schema)
|
|
20
|
+
const valid = validate(data)
|
|
21
|
+
|
|
22
|
+
if (!valid) {
|
|
23
|
+
console.error('Output validation failed:', validate.errors)
|
|
24
|
+
// Don't throw - log warning instead
|
|
25
|
+
}
|
|
26
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"moduleResolution": "node",
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|