@ixo/oracle-runtime-workers 0.2.0

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.
Files changed (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,564 @@
1
+ /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
2
+ /**
3
+ * The HTTP shell — a Hono app that speaks the same wire protocol as the Node
4
+ * runtime's NestJS modules (`/sessions`, `/messages`, `/delegation`,
5
+ * `/health`, `/models`) so the Portal, the CLI and `@ixo/oracles-client-sdk`
6
+ * work unchanged. Every authenticated route resolves the caller's
7
+ * `UserOracleDO` and forwards to it; streaming turns are proxied byte-for-byte.
8
+ */
9
+ import { Hono } from 'hono';
10
+ import { cors } from 'hono/cors';
11
+ import type { OracleWorkerEnv, TurnIdentity } from '../do/contracts';
12
+ import { parseOwnerCopyFailure } from '../owner-store/owner-copy-errors';
13
+ import { userObjectName } from '../do/contracts';
14
+ import { ROUTED_USER_HEADER } from '../realtime/realtime-endpoint';
15
+ import {
16
+ decodeRoomStateContent,
17
+ encodeRoomStateContent,
18
+ } from '../matrix/room-state-codec';
19
+ import {
20
+ authenticate,
21
+ isExcluded,
22
+ type AuthResult,
23
+ type RouteExclusion,
24
+ } from './auth';
25
+
26
+ export interface PluginRoute {
27
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'ALL';
28
+ path: string;
29
+ /**
30
+ * `ctx.auth` carries the validated caller identity for routes that are NOT
31
+ * auth-excluded; it is null on excluded routes. Handlers may ignore it.
32
+ */
33
+ handler: (
34
+ request: Request,
35
+ env: OracleWorkerEnv,
36
+ ctx: { auth: AuthResult | null },
37
+ ) => Response | Promise<Response>;
38
+ }
39
+
40
+ export interface ShellOptions {
41
+ /** Routes contributed by plugins (`getRoutes`) and the host. */
42
+ routes?: PluginRoute[];
43
+ /** Auth exclusions contributed by plugins and the host. */
44
+ authExcludedRoutes?: RouteExclusion[];
45
+ /** `GET /models` payload provider (may fetch live prices). */
46
+ listModels?: (env: OracleWorkerEnv) => unknown | Promise<unknown>;
47
+ /** Version banner for `GET /`. */
48
+ banner?: { name: string; description?: string };
49
+ }
50
+
51
+ type Variables = { auth: AuthResult };
52
+
53
+ const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
54
+ { path: '/', method: 'GET' },
55
+ { path: '/health', method: 'ALL' },
56
+ { path: '/health/*', method: 'ALL' },
57
+ { path: '/models', method: 'GET' },
58
+ { path: '/matrix/status', method: 'GET' },
59
+ { path: '/matrix/start', method: 'POST' },
60
+ // Operator-only; the /debug/* middleware 404s unless ORACLE_DEBUG_ROUTES=true.
61
+ { path: '/debug/matrix/restart', method: 'POST' },
62
+ { path: '/debug/matrix/stop', method: 'POST' },
63
+ ];
64
+
65
+ /** Express body-parser default in the Node runtime: JSON bodies over 100 KiB are a 413. */
66
+ const MAX_TURN_BODY_BYTES = 100 * 1024;
67
+
68
+ export function createShell(
69
+ opts: ShellOptions = {},
70
+ ): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
71
+ const app = new Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }>();
72
+ const exclusions = [
73
+ ...BUILTIN_EXCLUSIONS,
74
+ ...(opts.authExcludedRoutes ?? []),
75
+ ];
76
+
77
+ // --- realtime (socket.io) --------------------------------------------------
78
+ // Registered before every middleware on purpose: a WebSocket upgrade
79
+ // carries no auth headers (browsers cannot set them), so the UCAN material
80
+ // travels in the socket.io CONNECT packet and the user object validates it
81
+ // — the shell only routes on `?userDid`, and the object refuses a token
82
+ // that does not belong to that DID. The 101 response must also reach the
83
+ // client untouched (no CORS header rewriting on an upgraded response).
84
+ app.all('/socket.io/*', async (c) => {
85
+ const userDid = c.req.query('userDid');
86
+ if (!userDid) {
87
+ return c.json(
88
+ { code: 3, message: 'userDid query parameter is required.' },
89
+ 400,
90
+ );
91
+ }
92
+ if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
93
+ return c.json(
94
+ {
95
+ code: 3,
96
+ message:
97
+ 'Expected a WebSocket upgrade: this runtime serves socket.io over the websocket transport only (connect with transports: ["websocket"]).',
98
+ },
99
+ 426,
100
+ );
101
+ }
102
+ const target = new URL(c.req.url);
103
+ target.protocol = 'https:';
104
+ target.host = 'user-oracle';
105
+ const forwarded = new Request(target, c.req.raw);
106
+ forwarded.headers.set(ROUTED_USER_HEADER, userDid);
107
+ return userStub(c.env, userDid).fetch(forwarded);
108
+ });
109
+
110
+ app.use('*', async (c, next) => {
111
+ const origin = c.env.CORS_ORIGIN ?? '*';
112
+ return cors({
113
+ origin,
114
+ credentials: origin !== '*',
115
+ allowHeaders: [
116
+ 'content-type',
117
+ 'authorization',
118
+ 'x-auth-type',
119
+ 'x-ucan-delegation',
120
+ 'x-did',
121
+ 'x-timezone',
122
+ 'x-request-id',
123
+ 'x-matrix-access-token',
124
+ 'accept',
125
+ ],
126
+ exposeHeaders: ['x-request-id'],
127
+ })(c, next);
128
+ });
129
+
130
+ // --- public ----------------------------------------------------------------
131
+ app.get('/', (c) =>
132
+ c.json({
133
+ status: 'ok',
134
+ message: `${opts.banner?.name ?? c.env.ORACLE_NAME ?? 'QiForge oracle'} is running on Cloudflare Workers`,
135
+ ...(opts.banner?.description && { description: opts.banner.description }),
136
+ timestamp: new Date().toISOString(),
137
+ }),
138
+ );
139
+ app.get('/health', (c) =>
140
+ c.json({ status: 'ok', timestamp: new Date().toISOString() }),
141
+ );
142
+ app.get('/health/matrix', async (c) => {
143
+ const status = await gateway(c.env).status();
144
+ return c.json(status, status.running ? 200 : 503);
145
+ });
146
+ app.get('/matrix/status', async (c) => c.json(await gateway(c.env).status()));
147
+ app.post('/matrix/start', async (c) =>
148
+ c.json(await gateway(c.env).ensureStarted()),
149
+ );
150
+ app.get('/models', async (c) =>
151
+ c.json(
152
+ (await opts.listModels?.(c.env)) ?? {
153
+ models: [],
154
+ // Node's `ModelListing` shape (`{ models, default }`); the client SDK
155
+ // reads `default`.
156
+ default: c.env.DEFAULT_MODEL ?? null,
157
+ },
158
+ ),
159
+ );
160
+
161
+ // --- auth ------------------------------------------------------------------
162
+ app.use('*', async (c, next) => {
163
+ if (isExcluded(c.req.method, c.req.path, exclusions)) return next();
164
+ const outcome = await authenticate(c.req.raw.headers, {
165
+ oracleDid: c.env.ORACLE_DID,
166
+ blocksyncUri: c.env.BLOCKSYNC_GRAPHQL_URL,
167
+ maxTtlSeconds: c.env.UCAN_AUTH_MAX_TTL_SECONDS
168
+ ? Number(c.env.UCAN_AUTH_MAX_TTL_SECONDS)
169
+ : undefined,
170
+ });
171
+ if (!outcome.ok)
172
+ return c.json(
173
+ { statusCode: outcome.status, message: outcome.error },
174
+ outcome.status as 401,
175
+ );
176
+ c.set('auth', outcome.auth);
177
+ return next();
178
+ });
179
+
180
+ // --- rate limit (per authenticated user DID) -------------------------------
181
+ // Cloudflare's native limiter; the limit/period live on the binding in
182
+ // wrangler.jsonc. Keyed by DID rather than IP so co-located users (one
183
+ // NAT/proxy) never share a bucket and a single leaked token can't burn
184
+ // unbounded LLM spend. Auth-excluded routes (health/models/matrix status)
185
+ // ran `next()` above and never reach here. Absent binding = no limiting
186
+ // (local harness / tests).
187
+ app.use('*', async (c, next) => {
188
+ const limiter = c.env.RATE_LIMIT;
189
+ if (!limiter) return next();
190
+ const auth = c.get('auth') as AuthResult | undefined;
191
+ if (!auth) return next();
192
+ const { success } = await limiter.limit({ key: auth.userDid });
193
+ if (!success)
194
+ return c.json(
195
+ {
196
+ statusCode: 429,
197
+ message:
198
+ 'Rate limit exceeded — too many requests. Please slow down and try again shortly.',
199
+ },
200
+ 429,
201
+ );
202
+ return next();
203
+ });
204
+
205
+ // --- sessions ----------------------------------------------------------------
206
+ app.post('/sessions', async (c) => {
207
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
208
+ const session = await userStub(c.env, identity.userDid).createSession(
209
+ identity,
210
+ );
211
+ return c.json(session, 201);
212
+ });
213
+ app.get('/sessions', async (c) => {
214
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
215
+ const limit = c.req.query('limit')
216
+ ? Number(c.req.query('limit'))
217
+ : undefined;
218
+ const offset = c.req.query('offset')
219
+ ? Number(c.req.query('offset'))
220
+ : undefined;
221
+ const { sessions, total } = await userStub(
222
+ c.env,
223
+ identity.userDid,
224
+ ).listSessions(identity, { limit, offset });
225
+ // Node's `ListChatSessionsResponseDto`: `{ sessions, total }`.
226
+ return c.json({ sessions, total });
227
+ });
228
+ app.delete('/sessions/:sessionId', async (c) => {
229
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
230
+ const ok = await userStub(c.env, identity.userDid).deleteSession(
231
+ identity,
232
+ c.req.param('sessionId'),
233
+ );
234
+ return ok
235
+ ? c.json({ message: 'Session deleted successfully' })
236
+ : c.json({ message: 'Session not found' }, 404);
237
+ });
238
+
239
+ // --- messages ----------------------------------------------------------------
240
+ app.post('/messages/abort', async (c) => {
241
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
242
+ const body = (await c.req.json().catch(() => ({}))) as {
243
+ sessionId?: string;
244
+ };
245
+ if (!body.sessionId)
246
+ return c.json({ message: 'sessionId is required' }, 400);
247
+ const success = await userStub(c.env, identity.userDid).abortTurn(
248
+ body.sessionId,
249
+ );
250
+ return c.json({ success });
251
+ });
252
+ app.get('/messages/:sessionId', async (c) => {
253
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
254
+ const messages = await userStub(c.env, identity.userDid).listMessages(
255
+ identity,
256
+ c.req.param('sessionId'),
257
+ );
258
+ return new Response(`{"messages":${messages}}`, {
259
+ headers: { 'content-type': 'application/json' },
260
+ });
261
+ });
262
+ app.post('/messages/:sessionId', async (c) => {
263
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
264
+ const sessionId = c.req.param('sessionId');
265
+ const body = await c.req.text();
266
+ // Node (express body-parser default) refuses JSON bodies over 100 KiB
267
+ // with a 413; attachments are references, so real turns never get near it.
268
+ if (new TextEncoder().encode(body).byteLength > MAX_TURN_BODY_BYTES) {
269
+ return c.json(
270
+ { statusCode: 413, message: 'request entity too large' },
271
+ 413,
272
+ );
273
+ }
274
+ // Node accepts a client-supplied `requestId` (correlates the SSE events
275
+ // with the client's own bookkeeping); fall back to a fresh UUID.
276
+ const requestId = clientRequestId(body) ?? crypto.randomUUID();
277
+ // The user object owns the turn; it returns either an SSE stream or JSON.
278
+ // We pass the raw body through so attachments/tools/agActions survive.
279
+ const res = await userStub(c.env, identity.userDid).fetch(
280
+ `https://user-oracle/turn/${encodeURIComponent(sessionId)}`,
281
+ {
282
+ method: 'POST',
283
+ headers: {
284
+ 'content-type': 'application/json',
285
+ 'x-request-id': requestId,
286
+ 'x-identity': JSON.stringify(identity),
287
+ },
288
+ body,
289
+ },
290
+ );
291
+ const headers = new Headers(res.headers);
292
+ headers.set('x-request-id', requestId);
293
+ headers.set('access-control-expose-headers', 'x-request-id');
294
+ return new Response(res.body, { status: res.status, headers });
295
+ });
296
+
297
+ // --- delegation (room-state persisted, for header-less Matrix turns) -----------
298
+ app.post('/delegation', async (c) => {
299
+ const auth = c.get('auth');
300
+ const body = (await c.req.json().catch(() => ({}))) as {
301
+ raw?: string;
302
+ expiration?: number;
303
+ };
304
+ const raw = body.raw ?? auth.delegation;
305
+ if (!raw) return c.json({ message: 'raw delegation is required' }, 400);
306
+ const room = await gateway(c.env).resolveUserRoom(auth.userDid);
307
+ if (!room) return c.json({ message: 'No oracle room for this user' }, 404);
308
+ const expiration = body.expiration ?? auth.delegationExpiration;
309
+ // The Node runtime's `DelegationStore` record, in its compressed
310
+ // room-state envelope, so a user can move between runtimes without
311
+ // re-authorising (`updatedAt` is required by Node's schema).
312
+ await gateway(c.env).sendStateEvent(
313
+ room.roomId,
314
+ 'ixo.room.state',
315
+ JSON.stringify(
316
+ await encodeRoomStateContent({
317
+ raw,
318
+ issuer: auth.userDid,
319
+ audience: c.env.ORACLE_DID,
320
+ ...(expiration && { expiration }),
321
+ updatedAt: new Date().toISOString(),
322
+ }),
323
+ ),
324
+ 'ucan_delegation',
325
+ );
326
+ // The user's object caches the delegation for header-less (Matrix)
327
+ // turns; hand it the new one so it takes effect immediately rather
328
+ // than when the cached copy expires (Node's cacheDelegation semantics).
329
+ await userStub(c.env, auth.userDid)
330
+ .setDelegation(auth.userDid, raw, expiration)
331
+ .catch((err: unknown) => {
332
+ console.warn(
333
+ `[shell] could not push the deposited delegation to ${auth.userDid}'s object: ${err instanceof Error ? err.message : String(err)}`,
334
+ );
335
+ });
336
+ return c.json({
337
+ ok: true,
338
+ expiration: body.expiration ?? auth.delegationExpiration,
339
+ });
340
+ });
341
+ app.get('/delegation', async (c) => {
342
+ const auth = c.get('auth');
343
+ const room = await gateway(c.env).resolveUserRoom(auth.userDid);
344
+ if (!room) return c.json({ authorized: false });
345
+ const raw = await gateway(c.env).getRoomStateEvent(
346
+ room.roomId,
347
+ 'ixo.room.state',
348
+ 'ucan_delegation',
349
+ );
350
+ const state = raw
351
+ ? ((await decodeRoomStateContent(JSON.parse(raw) as unknown)) as {
352
+ raw?: string;
353
+ expiration?: number;
354
+ } | null)
355
+ : null;
356
+ const exp =
357
+ typeof state?.expiration === 'number' ? state.expiration : undefined;
358
+ const authorized =
359
+ Boolean(state?.raw) &&
360
+ (exp === undefined || exp > Math.floor(Date.now() / 1000));
361
+ return c.json({
362
+ authorized,
363
+ ...(exp !== undefined && { expiration: exp }),
364
+ });
365
+ });
366
+ app.delete('/delegation', async (c) => {
367
+ const auth = c.get('auth');
368
+ const room = await gateway(c.env).resolveUserRoom(auth.userDid);
369
+ if (room)
370
+ await gateway(c.env).sendStateEvent(
371
+ room.roomId,
372
+ 'ixo.room.state',
373
+ JSON.stringify(await encodeRoomStateContent({})),
374
+ 'ucan_delegation',
375
+ );
376
+ // Node's revokeDelegationForUser clears the cache so downstream minting
377
+ // stops NOW, not at the cache TTL. Same here: drop the object's copy.
378
+ await userStub(c.env, auth.userDid)
379
+ .clearDelegation(auth.userDid)
380
+ .catch((err: unknown) => {
381
+ console.warn(
382
+ `[shell] could not clear ${auth.userDid}'s cached delegation: ${err instanceof Error ? err.message : String(err)}`,
383
+ );
384
+ });
385
+ return c.json({ ok: true });
386
+ });
387
+
388
+ // --- byo-llm (bring-your-own-credential LLMs) --------------------------------
389
+ // The user's object owns the credentials (secrets service, refresh state),
390
+ // so every route is proxied there — same pattern as streaming turns. The
391
+ // shell only short-circuits the disabled case so stray requests never boot
392
+ // a Durable Object.
393
+ app.all('/byo-llm/*', async (c) => {
394
+ if (c.env.BYO_LLM_ENABLED !== 'true') {
395
+ return c.req.method === 'GET' && c.req.path === '/byo-llm/status'
396
+ ? c.json({ enabled: false, providers: [] })
397
+ : c.json(
398
+ {
399
+ statusCode: 404,
400
+ message:
401
+ 'Bring-your-own-credential LLMs are not enabled on this oracle',
402
+ },
403
+ 404,
404
+ );
405
+ }
406
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
407
+ const url = new URL(c.req.raw.url);
408
+ const method = c.req.method.toUpperCase();
409
+ const init: RequestInit = {
410
+ method,
411
+ headers: {
412
+ 'content-type': 'application/json',
413
+ 'x-identity': JSON.stringify(identity),
414
+ },
415
+ };
416
+ if (method !== 'GET' && method !== 'HEAD') init.body = await c.req.text();
417
+ const res = await userStub(c.env, identity.userDid).fetch(
418
+ `https://user-oracle${url.pathname}${url.search}`,
419
+ init,
420
+ );
421
+ return new Response(res.body, { status: res.status, headers: res.headers });
422
+ });
423
+
424
+ // --- debug / operator routes (enabled with ORACLE_DEBUG_ROUTES=true) -------------
425
+ app.use('/debug/*', async (c, next) => {
426
+ if (c.env.ORACLE_DEBUG_ROUTES !== 'true')
427
+ return c.json({ statusCode: 404, message: 'Not found' }, 404);
428
+ return next();
429
+ });
430
+ app.get('/debug/storage', async (c) =>
431
+ c.json(await userStub(c.env, c.get('auth').userDid).storageStatus()),
432
+ );
433
+ app.post('/debug/storage/flush', async (c) =>
434
+ c.json(await userStub(c.env, c.get('auth').userDid).flushToOwnerStore()),
435
+ );
436
+ app.post('/debug/storage/reset', async (c) =>
437
+ c.json(await userStub(c.env, c.get('auth').userDid).resetWorkingCopy()),
438
+ );
439
+ app.get('/debug/memory-schema', async (c) => {
440
+ const userDid = c.get('auth').userDid;
441
+ return c.json(await userStub(c.env, userDid).debugMemorySchema(userDid));
442
+ });
443
+ app.get('/debug/sessions/:sessionId', async (c) => {
444
+ const userDid = c.get('auth').userDid;
445
+ const row = await userStub(c.env, userDid).debugSession(
446
+ userDid,
447
+ c.req.param('sessionId'),
448
+ );
449
+ return row
450
+ ? c.json(row)
451
+ : c.json({ statusCode: 404, message: 'Session not found' }, 404);
452
+ });
453
+ app.get('/debug/tasks', async (c) =>
454
+ c.json(await userStub(c.env, c.get('auth').userDid).tasksStatus()),
455
+ );
456
+ app.get('/debug/realtime', async (c) =>
457
+ c.json(await userStub(c.env, c.get('auth').userDid).realtimeStatus()),
458
+ );
459
+ app.get('/debug/delegation', async (c) =>
460
+ c.json(
461
+ await userStub(c.env, c.get('auth').userDid).delegationStatus(
462
+ c.get('auth').userDid,
463
+ ),
464
+ ),
465
+ );
466
+ app.post('/debug/matrix/rotate-device', async (c) =>
467
+ c.json(await gateway(c.env).rotateDevice('operator')),
468
+ );
469
+ app.get('/debug/matrix/outbox', async (c) =>
470
+ c.json({ rows: await gateway(c.env).listOutbox() }),
471
+ );
472
+ app.post('/debug/matrix/restart', async (c) =>
473
+ c.json(await gateway(c.env).restart()),
474
+ );
475
+ app.post('/debug/matrix/stop', async (c) => {
476
+ await gateway(c.env).stop();
477
+ return c.json({ stopped: true });
478
+ });
479
+
480
+ // --- plugin / host routes ------------------------------------------------------
481
+ for (const route of opts.routes ?? []) {
482
+ const path = route.path.startsWith('/') ? route.path : `/${route.path}`;
483
+ const handler = async (c: {
484
+ req: { raw: Request };
485
+ env: OracleWorkerEnv;
486
+ get: (key: 'auth') => AuthResult | undefined;
487
+ }) => route.handler(c.req.raw, c.env, { auth: c.get('auth') ?? null });
488
+ if (route.method === 'ALL') app.all(path, handler);
489
+ else app.on(route.method, path, handler);
490
+ }
491
+
492
+ app.notFound((c) =>
493
+ c.json(
494
+ { statusCode: 404, message: `Cannot ${c.req.method} ${c.req.path}` },
495
+ 404,
496
+ ),
497
+ );
498
+ app.onError((err, c) => {
499
+ console.error(`[shell] ${c.req.method} ${c.req.path} failed:`, err);
500
+ // A cold boot that could not read the user's owner copy changed nothing.
501
+ // Say precisely why: 503 + retryable for a transient store/VFS failure
502
+ // (the object already retried with backoff), 403 for a missing
503
+ // `ixo:filesystem` delegation or rejected credentials, where retrying
504
+ // cannot help and the client must act.
505
+ // The user object throws these as an RPC envelope (a plain Error on
506
+ // this side); `parseOwnerCopyFailure` reads the class, the envelope or
507
+ // the raw store errors alike.
508
+ const failure = parseOwnerCopyFailure(err);
509
+ if (failure) {
510
+ return c.json(
511
+ {
512
+ statusCode: failure.httpStatus,
513
+ code: failure.code,
514
+ message: failure.message,
515
+ retryable: failure.retryable,
516
+ },
517
+ failure.httpStatus,
518
+ );
519
+ }
520
+ return c.json(
521
+ { statusCode: 500, message: err.message || 'Internal error' },
522
+ 500,
523
+ );
524
+ });
525
+
526
+ return app;
527
+ }
528
+
529
+ const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
530
+
531
+ /** The body's `requestId` when it is a sane opaque token; null otherwise. */
532
+ function clientRequestId(rawBody: string): string | null {
533
+ try {
534
+ const parsed: unknown = JSON.parse(rawBody);
535
+ if (parsed && typeof parsed === 'object') {
536
+ const id = (parsed as { requestId?: unknown }).requestId;
537
+ if (typeof id === 'string' && REQUEST_ID_RE.test(id)) return id;
538
+ }
539
+ } catch {
540
+ // not JSON — the user object reports the malformed body
541
+ }
542
+ return null;
543
+ }
544
+
545
+ function identityOf(auth: AuthResult, headers: Headers): TurnIdentity {
546
+ return {
547
+ userDid: auth.userDid,
548
+ ucanDelegation: auth.delegation,
549
+ ucanDelegationExpiration: auth.delegationExpiration,
550
+ timezone: headers.get('x-timezone') ?? undefined,
551
+ };
552
+ }
553
+
554
+ export function userStub(env: OracleWorkerEnv, userDid: string) {
555
+ const id = env.USER_ORACLE.idFromName(
556
+ userObjectName(userDid, env.ORACLE_DID),
557
+ );
558
+ return env.USER_ORACLE.get(id);
559
+ }
560
+
561
+ export function gateway(env: OracleWorkerEnv) {
562
+ const id = env.MATRIX_GATEWAY.idFromName(env.ORACLE_DID);
563
+ return env.MATRIX_GATEWAY.get(id);
564
+ }