@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +2003 -274
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +562 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
package/src/shell/app.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import {
2
+ authenticateChannel,
3
+ assertActiveChannelBinding,
4
+ channelAuthConfig,
5
+ } from '../channels/auth';
6
+ import {
7
+ ChannelError,
8
+ ChannelTurnBody,
9
+ channelRequestHash,
10
+ readChannelBody,
11
+ } from '../channels/contract';
1
12
  /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
2
13
  /**
3
14
  * The HTTP shell — a Hono app that speaks the same wire protocol as the Node
@@ -6,8 +17,18 @@
6
17
  * work unchanged. Every authenticated route resolves the caller's
7
18
  * `UserOracleDO` and forwards to it; streaming turns are proxied byte-for-byte.
8
19
  */
20
+ import { FRONTEND_BRIDGE } from '@ixo/common/ai/frontend-bridge';
21
+ import { bodyLimit } from 'hono/body-limit';
22
+ import { HTTPException } from 'hono/http-exception';
23
+ import {
24
+ TopicOperationId,
25
+ TopicDeliverableRequestSchema,
26
+ TOPIC_DELIVERABLE_BODY_BYTES,
27
+ type TopicDeliverableCommand,
28
+ } from '../tasks/topic-deliverables';
9
29
  import { parseTranscriptPageQuery } from '../do/transcript';
10
30
  import { Hono } from 'hono';
31
+ import { routePath } from 'hono/route';
11
32
  import { cors } from 'hono/cors';
12
33
  import type { OracleWorkerEnv, TurnIdentity } from '../do/contracts';
13
34
  import { parseOwnerCopyFailure } from '../owner-store/owner-copy-errors';
@@ -22,12 +43,26 @@ import {
22
43
  type DelegatedCapability,
23
44
  } from '../do/ucan-service';
24
45
  import {
46
+ authConfigFromEnv,
25
47
  authenticate,
26
48
  isExcluded,
49
+ validateDelegation,
27
50
  type AuthResult,
28
51
  type RouteExclusion,
29
52
  } from './auth';
30
- import { turnBodyTooLarge } from './turn-body-cap';
53
+ import { MAX_TURN_BODY_BYTES } from './turn-body-cap';
54
+ import {
55
+ artifactDataResponse,
56
+ artifactPageResponse,
57
+ } from '../artifacts/routes';
58
+ import { ARTIFACT_ID_RE } from '../artifacts/store';
59
+ import { FEEDBACK_BODY_MAX_BYTES } from '../feedback/contract';
60
+ import {
61
+ feedbackFailure,
62
+ submitFeedback,
63
+ withFeedbackCapability,
64
+ type FeedbackShellOptions,
65
+ } from '../feedback/submit';
31
66
  import { z } from 'zod';
32
67
 
33
68
  const DebugEventBody = z.object({
@@ -54,15 +89,48 @@ export interface PluginRoute {
54
89
  ) => Response | Promise<Response>;
55
90
  }
56
91
 
92
+ /** What `GET /models` hands its payload provider besides the env. */
93
+ export interface ListModelsOptions {
94
+ waitUntil?: (work: Promise<unknown>) => void;
95
+ }
96
+
97
+ /**
98
+ * The request's `waitUntil`, or undefined when the app runs without an
99
+ * execution context (Hono's `executionCtx` getter throws then).
100
+ */
101
+ function executionWaitUntil(c: {
102
+ readonly executionCtx: { waitUntil(work: Promise<unknown>): void };
103
+ }): ((work: Promise<unknown>) => void) | undefined {
104
+ let ctx: { waitUntil(work: Promise<unknown>): void };
105
+ try {
106
+ ctx = c.executionCtx;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ return (work) => ctx.waitUntil(work);
111
+ }
112
+
57
113
  export interface ShellOptions {
58
114
  /** Routes contributed by plugins (`getRoutes`) and the host. */
59
115
  routes?: PluginRoute[];
60
116
  /** Auth exclusions contributed by plugins and the host. */
61
117
  authExcludedRoutes?: RouteExclusion[];
62
- /** `GET /models` payload provider (may fetch live prices). */
63
- listModels?: (env: OracleWorkerEnv) => unknown | Promise<unknown>;
118
+ /**
119
+ * `GET /models` payload provider (may fetch live prices). `waitUntil`
120
+ * keeps work it starts in the background (a price refresh) alive past the
121
+ * response; absent when the request has no execution context.
122
+ */
123
+ listModels?: (
124
+ env: OracleWorkerEnv,
125
+ options?: ListModelsOptions,
126
+ ) => unknown | Promise<unknown>;
64
127
  /** Version banner for `GET /`. */
65
128
  banner?: { name: string; description?: string };
129
+ /**
130
+ * Anonymous Agent-response feedback (`src/feedback/`). Absent = off: the
131
+ * route answers 404 and transcripts do not advertise the capability.
132
+ */
133
+ feedback?: FeedbackShellOptions | null;
66
134
  }
67
135
 
68
136
  type Variables = { auth: AuthResult };
@@ -71,6 +139,20 @@ function errorText(err: unknown): string {
71
139
  return err instanceof Error ? err.message : String(err);
72
140
  }
73
141
 
142
+ /** Longest error message an unexpected channel failure logs. */
143
+ const CHANNEL_FAILURE_LOG_CHARS = 300;
144
+
145
+ /** `<name>: <message>` of an unexpected channel failure, the message bounded. */
146
+ function describeChannelFailure(err: unknown): string {
147
+ const name = err instanceof Error ? err.name : typeof err;
148
+ const message = errorText(err);
149
+ return `${name}: ${
150
+ message.length > CHANNEL_FAILURE_LOG_CHARS
151
+ ? `${message.slice(0, CHANNEL_FAILURE_LOG_CHARS)}…`
152
+ : message
153
+ }`;
154
+ }
155
+
74
156
  /** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
75
157
  function isAbortRejection(err: unknown): boolean {
76
158
  return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
@@ -78,18 +160,121 @@ function isAbortRejection(err: unknown): boolean {
78
160
  );
79
161
  }
80
162
 
81
- const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
163
+ /**
164
+ * Routes that answer without a UCAN invocation: the banner, liveness
165
+ * probes, the model list, and the gateway's status/start that monitors and
166
+ * the drills poll. Every one of them is read-only or idempotent; nothing
167
+ * that changes state for other users is here (the debug routes included —
168
+ * they authenticate like every other route).
169
+ */
170
+ export const BUILTIN_EXCLUSIONS: readonly RouteExclusion[] = [
82
171
  { path: '/', method: 'GET' },
83
172
  { path: '/health', method: 'ALL' },
84
173
  { path: '/health/*', method: 'ALL' },
85
174
  { path: '/models', method: 'GET' },
86
175
  { path: '/matrix/status', method: 'GET' },
87
176
  { path: '/matrix/start', method: 'POST' },
88
- // Operator-only; the /debug/* middleware 404s unless ORACLE_DEBUG_ROUTES=true.
89
- { path: '/debug/matrix/restart', method: 'POST' },
90
- { path: '/debug/matrix/stop', method: 'POST' },
91
177
  ];
92
178
 
179
+ // Body caps of the built-in routes (a turn's is `MAX_TURN_BODY_BYTES`).
180
+ /** `POST /messages/abort`: a session id. */
181
+ export const ABORT_BODY_BYTES = 4 * 1024;
182
+ /** `POST /delegation`: one serialized delegation (a base64 CAR with its proof chain). */
183
+ export const DELEGATION_BODY_BYTES = 64 * 1024;
184
+ /** `/byo-llm/*`: provider credentials and OAuth exchanges. */
185
+ export const BYO_LLM_BODY_BYTES = 64 * 1024;
186
+ /** `/debug/*`: operator drills (the largest is one Matrix event, itself capped at 64 KiB). */
187
+ export const DEBUG_BODY_BYTES = 64 * 1024;
188
+
189
+ /** A body cap enforced before the body is buffered: over it, 413 and the handler never runs. */
190
+ function limitBody(maxSize: number) {
191
+ return bodyLimit({
192
+ maxSize,
193
+ onError: (c) =>
194
+ c.json({ statusCode: 413, message: 'request entity too large' }, 413),
195
+ });
196
+ }
197
+
198
+ const DID_SEGMENT = String.raw`(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+`;
199
+ /** A W3C DID (`did:<method>:<method-specific-id>`), bounded in length. */
200
+ const DID_RE = new RegExp(`^did:[a-z0-9]+:${DID_SEGMENT}(?::${DID_SEGMENT})*$`);
201
+ const MAX_DID_LENGTH = 512;
202
+
203
+ export function isDidShaped(value: string): boolean {
204
+ return value.length <= MAX_DID_LENGTH && DID_RE.test(value);
205
+ }
206
+
207
+ /**
208
+ * The key of one rate-limit budget. Prefixed with the oracle's DID: rate
209
+ * limiter bindings that share a `namespace_id` share their counters, even
210
+ * across Workers, so two oracles on one account would otherwise share every
211
+ * user's budget.
212
+ */
213
+ export function rateLimitKey(
214
+ env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
215
+ scope: 'user' | 'artifact' | 'socket' | 'status' | 'start',
216
+ subject: string,
217
+ ): string {
218
+ return `${env.ORACLE_DID}|${scope}|${subject}`;
219
+ }
220
+
221
+ /**
222
+ * Whether an unauthenticated caller (keyed by its IP) is over its budget.
223
+ * No binding or no client IP (local runs, tests): never limited.
224
+ */
225
+ async function ipOverLimit(
226
+ env: OracleWorkerEnv,
227
+ scope: 'artifact' | 'socket' | 'status' | 'start',
228
+ ip: string | undefined,
229
+ ): Promise<boolean> {
230
+ if (!ip || !env.RATE_LIMIT) return false;
231
+ const { success } = await env.RATE_LIMIT.limit({
232
+ key: rateLimitKey(env, scope, ip),
233
+ });
234
+ return !success;
235
+ }
236
+
237
+ /**
238
+ * The operators allowed onto the gateway-wide debug routes
239
+ * (`ORACLE_OPERATOR_DIDS`, comma-separated DIDs): null when the variable is
240
+ * unset, an empty set when it is malformed (fail closed — nobody, and an
241
+ * error in the log). Parsed once per distinct value.
242
+ */
243
+ let operatorList: { raw: string; dids: ReadonlySet<string> } | null = null;
244
+
245
+ function operatorDids(env: OracleWorkerEnv): ReadonlySet<string> | null {
246
+ const raw = env.ORACLE_OPERATOR_DIDS;
247
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
248
+ if (operatorList?.raw === raw) return operatorList.dids;
249
+ const entries = raw
250
+ .split(',')
251
+ .map((did) => did.trim())
252
+ .filter(Boolean);
253
+ const malformed = entries.filter((did) => !isDidShaped(did));
254
+ if (malformed.length > 0)
255
+ console.error(
256
+ `[shell] ORACLE_OPERATOR_DIDS has entries that are not DIDs (${malformed.join(', ')}); no caller is let onto /debug/matrix/*`,
257
+ );
258
+ operatorList = {
259
+ raw,
260
+ dids: new Set(malformed.length > 0 ? [] : entries),
261
+ };
262
+ return operatorList.dids;
263
+ }
264
+
265
+ /** What `GET /health/matrix` answers: the gateway's in-memory `running` flag. */
266
+ async function gatewayRunning(env: OracleWorkerEnv): Promise<boolean> {
267
+ const res = await gateway(env).fetch('https://matrix-gateway/health');
268
+ if (!res.ok) return false;
269
+ const body: unknown = await res.json();
270
+ return (
271
+ typeof body === 'object' &&
272
+ body !== null &&
273
+ 'running' in body &&
274
+ body.running === true
275
+ );
276
+ }
277
+
93
278
  export function createShell(
94
279
  opts: ShellOptions = {},
95
280
  ): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
@@ -106,6 +291,9 @@ export function createShell(
106
291
  // — the shell only routes on `?userDid`, and the object refuses a token
107
292
  // that does not belong to that DID. The 101 response must also reach the
108
293
  // client untouched (no CORS header rewriting on an upgraded response).
294
+ // Unauthenticated until CONNECT, so before any object is addressed the
295
+ // DID must at least look like one and the caller's IP must be within its
296
+ // budget: each upgrade costs a user object a request and a held socket.
109
297
  app.all('/socket.io/*', async (c) => {
110
298
  const userDid = c.req.query('userDid');
111
299
  if (!userDid) {
@@ -114,6 +302,12 @@ export function createShell(
114
302
  400,
115
303
  );
116
304
  }
305
+ if (!isDidShaped(userDid)) {
306
+ return c.json(
307
+ { code: 3, message: 'userDid query parameter is not a DID.' },
308
+ 400,
309
+ );
310
+ }
117
311
  if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
118
312
  return c.json(
119
313
  {
@@ -124,6 +318,8 @@ export function createShell(
124
318
  426,
125
319
  );
126
320
  }
321
+ if (await ipOverLimit(c.env, 'socket', c.req.header('cf-connecting-ip')))
322
+ return c.json({ code: 3, message: 'Too many requests.' }, 429);
127
323
  const target = new URL(c.req.url);
128
324
  target.protocol = 'https:';
129
325
  target.host = 'user-oracle';
@@ -132,6 +328,22 @@ export function createShell(
132
328
  return userStub(c.env, userDid).fetch(forwarded);
133
329
  });
134
330
 
331
+ // --- artefact links (public) -----------------------------------------------
332
+ // Also ahead of CORS and auth: a link is opened by whoever holds it, and
333
+ // the data route answers any origin itself (a shared viewer on another
334
+ // host fetches it). Both serve only what is safe without the key, which
335
+ // stays in the link's fragment: a static page and ciphertext.
336
+ app.get('/a/:artifactId', (c) =>
337
+ artifactPageResponse(c.env, c.req.param('artifactId')),
338
+ );
339
+ app.get('/a/:artifactId/data', async (c) => {
340
+ if (await ipOverLimit(c.env, 'artifact', c.req.header('cf-connecting-ip')))
341
+ return c.text('Too many requests', 429, {
342
+ 'access-control-allow-origin': '*',
343
+ });
344
+ return artifactDataResponse(c.env, c.req.param('artifactId'));
345
+ });
346
+
135
347
  app.use('*', async (c, next) => {
136
348
  const origin = c.env.CORS_ORIGIN ?? '*';
137
349
  return cors({
@@ -161,43 +373,137 @@ export function createShell(
161
373
  timestamp: new Date().toISOString(),
162
374
  }),
163
375
  );
376
+ // `frontendTools` is the frontend bridge version: the Portal enables
377
+ // conversational writes only against a runtime that advertises it. A
378
+ // version signal, not proof the deployment behaves (see frontend-bridge.md).
164
379
  app.get('/health', (c) =>
165
- c.json({ status: 'ok', timestamp: new Date().toISOString() }),
380
+ c.json({
381
+ status: 'ok',
382
+ timestamp: new Date().toISOString(),
383
+ frontendTools: FRONTEND_BRIDGE,
384
+ }),
166
385
  );
386
+ // A liveness probe: the gateway's in-memory flag, no storage read. The
387
+ // full picture is `/matrix/status`.
167
388
  app.get('/health/matrix', async (c) => {
168
- const status = await gateway(c.env).status();
169
- return c.json(status, status.running ? 200 : 503);
389
+ const running = await gatewayRunning(c.env).catch((err: unknown) => {
390
+ console.warn(`[shell] gateway health check failed: ${errorText(err)}`);
391
+ return false;
392
+ });
393
+ return c.json({ running }, running ? 200 : 503);
170
394
  });
171
- app.get('/matrix/status', async (c) => c.json(await gateway(c.env).status()));
172
- app.post('/matrix/start', async (c) =>
173
- c.json(await gateway(c.env).ensureStarted()),
174
- );
175
- app.get('/models', async (c) =>
176
- c.json(
177
- (await opts.listModels?.(c.env)) ?? {
395
+ // Public (monitors and the drills poll it), so limited per IP; the
396
+ // gateway serves the storage-scanning part from a short cache.
397
+ app.get('/matrix/status', async (c) => {
398
+ if (await ipOverLimit(c.env, 'status', c.req.header('cf-connecting-ip')))
399
+ return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
400
+ return c.json(await gateway(c.env).status());
401
+ });
402
+ app.post('/matrix/start', async (c) => {
403
+ if (await ipOverLimit(c.env, 'start', c.req.header('cf-connecting-ip')))
404
+ return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
405
+ return c.json(await gateway(c.env).ensureStarted());
406
+ });
407
+ app.get('/models', async (c) => {
408
+ const waitUntil = executionWaitUntil(c);
409
+ return c.json(
410
+ (await opts.listModels?.(c.env, waitUntil ? { waitUntil } : {})) ?? {
178
411
  models: [],
179
412
  // Node's `ModelListing` shape (`{ models, default }`); the client SDK
180
413
  // reads `default`.
181
414
  default: c.env.DEFAULT_MODEL ?? null,
182
415
  },
183
- ),
184
- );
416
+ );
417
+ });
418
+
419
+ app.post('/channels/turn', async (c) => {
420
+ try {
421
+ if (!c.env.CHANNEL_SERVICE_DID)
422
+ throw new ChannelError(
423
+ 503,
424
+ 'Channels are not configured',
425
+ 'not_configured',
426
+ );
427
+ const raw = await readChannelBody(c.req.raw);
428
+ let json: unknown;
429
+ try {
430
+ json = JSON.parse(raw);
431
+ } catch {
432
+ throw new ChannelError(400, 'Invalid JSON request', 'invalid_request');
433
+ }
434
+ const parsed = ChannelTurnBody.safeParse(json);
435
+ if (!parsed.success)
436
+ throw new ChannelError(400, 'Invalid channel turn', 'invalid_request');
437
+ const identity = await authenticateChannel(
438
+ c.req.raw.headers,
439
+ raw,
440
+ parsed.data,
441
+ channelAuthConfig(c.env),
442
+ );
443
+ // Limited as soon as the user is proven, before the Auth Hub round
444
+ // trip: a flood or a tight poll must not cost one call there each.
445
+ if (
446
+ c.env.RATE_LIMIT &&
447
+ !(
448
+ await c.env.RATE_LIMIT.limit({
449
+ key: rateLimitKey(c.env, 'user', identity.userDid),
450
+ })
451
+ ).success
452
+ )
453
+ throw new ChannelError(429, 'Too many requests', 'rate_limited');
454
+ await assertActiveChannelBinding(identity, c.env);
455
+ const outcome = await userStub(c.env, identity.userDid).channelTurn(
456
+ identity,
457
+ parsed.data,
458
+ await channelRequestHash(raw),
459
+ );
460
+ if (!outcome.ok)
461
+ return c.json(
462
+ { code: outcome.code, message: outcome.message },
463
+ outcome.status,
464
+ );
465
+ const result = outcome.result;
466
+ return c.json(
467
+ result,
468
+ ['queued', 'running', 'recovering'].includes(result.status) ? 202 : 200,
469
+ );
470
+ } catch (error) {
471
+ if (error instanceof ChannelError)
472
+ return c.json(
473
+ { code: error.code, message: error.message },
474
+ error.status,
475
+ );
476
+ // The cause only, never the request or its tokens: a 503 the gateway
477
+ // retries must leave a trace of why it happened.
478
+ console.error(
479
+ `[channels] turn failed unexpectedly: ${describeChannelFailure(error)}`,
480
+ );
481
+ return c.json(
482
+ {
483
+ code: 'unavailable',
484
+ message: 'Channel turn is temporarily unavailable',
485
+ },
486
+ 503,
487
+ );
488
+ }
489
+ });
185
490
 
186
491
  // --- auth ------------------------------------------------------------------
187
492
  app.use('*', async (c, next) => {
188
493
  if (isExcluded(c.req.method, c.req.path, exclusions)) return next();
189
- const outcome = await authenticate(c.req.raw.headers, {
190
- oracleDid: c.env.ORACLE_DID,
191
- blocksyncUri: c.env.BLOCKSYNC_GRAPHQL_URL,
192
- maxTtlSeconds: c.env.UCAN_AUTH_MAX_TTL_SECONDS
193
- ? Number(c.env.UCAN_AUTH_MAX_TTL_SECONDS)
194
- : undefined,
195
- });
494
+ const outcome = await authenticate(
495
+ c.req.raw.headers,
496
+ authConfigFromEnv(c.env),
497
+ );
196
498
  if (!outcome.ok)
197
499
  return c.json(
198
500
  { statusCode: outcome.status, message: outcome.error },
199
501
  outcome.status as 401,
200
502
  );
503
+ if (outcome.auth.via === 'delegation')
504
+ console.warn(
505
+ `[auth] ${c.req.method} ${loggedRoute(c)}: ${outcome.auth.userDid} authenticated with a bare delegation (UCAN_ALLOW_BARE_DELEGATION_AUTH); the client must send a UCAN invocation before the fallback is turned off`,
506
+ );
201
507
  c.set('auth', outcome.auth);
202
508
  return next();
203
509
  });
@@ -206,15 +512,17 @@ export function createShell(
206
512
  // Cloudflare's native limiter; the limit/period live on the binding in
207
513
  // wrangler.jsonc. Keyed by DID rather than IP so co-located users (one
208
514
  // NAT/proxy) never share a bucket and a single leaked token can't burn
209
- // unbounded LLM spend. Auth-excluded routes (health/models/matrix status)
210
- // ran `next()` above and never reach here. Absent binding = no limiting
211
- // (local harness / tests).
515
+ // unbounded LLM spend; the key carries the oracle's DID (`rateLimitKey`).
516
+ // Auth-excluded routes (health/models/matrix status) ran `next()` above
517
+ // and never reach here. Absent binding = no limiting (local harness / tests).
212
518
  app.use('*', async (c, next) => {
213
519
  const limiter = c.env.RATE_LIMIT;
214
520
  if (!limiter) return next();
215
521
  const auth = c.get('auth') as AuthResult | undefined;
216
522
  if (!auth) return next();
217
- const { success } = await limiter.limit({ key: auth.userDid });
523
+ const { success } = await limiter.limit({
524
+ key: rateLimitKey(c.env, 'user', auth.userDid),
525
+ });
218
526
  if (!success)
219
527
  return c.json(
220
528
  {
@@ -261,8 +569,89 @@ export function createShell(
261
569
  : c.json({ message: 'Session not found' }, 404);
262
570
  });
263
571
 
572
+ app.use('/topic-deliverables/*', async (c, next) => {
573
+ if (c.env.TOPIC_DELIVERABLES_ENABLED !== 'true')
574
+ return c.json({ message: 'Not found.' }, 404);
575
+ if (c.get('auth')?.via !== 'invocation')
576
+ return c.json({ message: 'A signed invocation is required.' }, 401);
577
+ return next();
578
+ });
579
+ app.use(
580
+ '/topic-deliverables/*',
581
+ bodyLimit({
582
+ maxSize: TOPIC_DELIVERABLE_BODY_BYTES,
583
+ onError: (c) => c.json({ message: 'Request is too large.' }, 413),
584
+ }),
585
+ );
586
+ app.on(
587
+ ['PUT', 'GET', 'POST'],
588
+ [
589
+ '/topic-deliverables/:operationId',
590
+ '/topic-deliverables/:operationId/cancel',
591
+ ],
592
+ async (c) => {
593
+ const cancelling =
594
+ c.req.routePath === '/topic-deliverables/:operationId/cancel';
595
+ if (cancelling !== (c.req.method === 'POST'))
596
+ return c.json({ message: 'Not found.' }, 404);
597
+ const operationId = TopicOperationId.safeParse(
598
+ c.req.param('operationId'),
599
+ );
600
+ if (!operationId.success)
601
+ return c.json({ message: 'Invalid operation ID.' }, 400);
602
+ let command: TopicDeliverableCommand;
603
+ if (c.req.method === 'PUT' || cancelling) {
604
+ const parsed = TopicDeliverableRequestSchema.safeParse(
605
+ await c.req.json().catch(() => null),
606
+ );
607
+ if (!parsed.success)
608
+ return c.json({ message: 'Invalid deliverable request.' }, 400);
609
+ command = {
610
+ action: cancelling ? 'cancel' : 'start',
611
+ request: parsed.data,
612
+ };
613
+ } else {
614
+ command = { action: 'read' };
615
+ }
616
+ const auth = c.get('auth');
617
+ const result = await userStub(c.env, auth.userDid).topicDeliverable(
618
+ identityOf(auth, c.req.raw.headers),
619
+ operationId.data,
620
+ command,
621
+ );
622
+ return result.ok
623
+ ? c.json(result.snapshot)
624
+ : c.json({ message: result.message }, result.status);
625
+ },
626
+ );
627
+
628
+ // --- artefacts (owner) ---------------------------------------------------------
629
+ app.get('/artifacts/:artifactId', async (c) => {
630
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
631
+ const artifactId = c.req.param('artifactId');
632
+ const artifact = ARTIFACT_ID_RE.test(artifactId)
633
+ ? await userStub(c.env, identity.userDid).artifact(identity, artifactId)
634
+ : null;
635
+ return artifact
636
+ ? c.json(artifact)
637
+ : c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
638
+ });
639
+ app.delete('/artifacts/:artifactId', async (c) => {
640
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
641
+ const artifactId = c.req.param('artifactId');
642
+ const revoked =
643
+ ARTIFACT_ID_RE.test(artifactId) &&
644
+ (await userStub(c.env, identity.userDid).revokeArtifact(
645
+ identity,
646
+ artifactId,
647
+ ));
648
+ return revoked
649
+ ? c.json({ revoked: true })
650
+ : c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
651
+ });
652
+
264
653
  // --- messages ----------------------------------------------------------------
265
- app.post('/messages/abort', async (c) => {
654
+ app.post('/messages/abort', limitBody(ABORT_BODY_BYTES), async (c) => {
266
655
  const identity = identityOf(c.get('auth'), c.req.raw.headers);
267
656
  const body = (await c.req.json().catch(() => ({}))) as {
268
657
  sessionId?: string;
@@ -280,10 +669,43 @@ export function createShell(
280
669
  identity,
281
670
  c.req.param('sessionId'),
282
671
  );
283
- return new Response(`{"messages":${messages}}`, {
284
- headers: { 'content-type': 'application/json' },
285
- });
672
+ return new Response(
673
+ withFeedbackCapability(`{"messages":${messages}}`, !!opts.feedback),
674
+ { headers: { 'content-type': 'application/json' } },
675
+ );
286
676
  });
677
+ // Anonymous feedback on one completed Agent reply (src/feedback/submit.ts).
678
+ app.post(
679
+ '/messages/:sessionId/:messageId/feedback',
680
+ bodyLimit({
681
+ maxSize: FEEDBACK_BODY_MAX_BYTES,
682
+ onError: (c) =>
683
+ c.json({ statusCode: 413, message: 'request entity too large' }, 413),
684
+ }),
685
+ async (c) => {
686
+ if (!opts.feedback) {
687
+ const off = feedbackFailure(
688
+ 404,
689
+ 'FEEDBACK_DISABLED',
690
+ 'Anonymous feedback is not enabled on this oracle',
691
+ );
692
+ return c.json(off.body, off.status);
693
+ }
694
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
695
+ const result = await submitFeedback({
696
+ options: opts.feedback,
697
+ user: userStub(c.env, identity.userDid),
698
+ identity,
699
+ sessionId: c.req.param('sessionId'),
700
+ messageId: c.req.param('messageId'),
701
+ body: await c.req.json<unknown>().catch(() => undefined),
702
+ clientIp: c.req.header('cf-connecting-ip'),
703
+ ipLimiter: c.env.FEEDBACK_RATE_LIMIT ?? c.env.RATE_LIMIT,
704
+ log: console,
705
+ });
706
+ return c.json(result.body, result.status);
707
+ },
708
+ );
287
709
  // One turn-aligned page of the transcript (docs/plans/transcript-paging.md):
288
710
  // the newest `limit` turns, the turns `before=` a cursor, or what came
289
711
  // `after=` one. The legacy listing above stays the whole transcript.
@@ -299,7 +721,7 @@ export function createShell(
299
721
  );
300
722
  if (!page.ok)
301
723
  return c.json({ statusCode: page.status, message: page.message }, 400);
302
- return new Response(page.json, {
724
+ return new Response(withFeedbackCapability(page.json, !!opts.feedback), {
303
725
  headers: { 'content-type': 'application/json' },
304
726
  });
305
727
  });
@@ -332,53 +754,64 @@ export function createShell(
332
754
  );
333
755
  return new Response(res.body, { status: res.status, headers });
334
756
  });
335
- app.post('/messages/:sessionId', async (c) => {
336
- const identity = identityOf(c.get('auth'), c.req.raw.headers);
337
- const sessionId = c.req.param('sessionId');
338
- const body = await c.req.text();
339
- // Bounds the memory one request can pin (see turn-body-cap.ts); the
340
- // status and message are the ones Node's body parser uses.
341
- if (turnBodyTooLarge(body)) {
342
- return c.json(
343
- { statusCode: 413, message: 'request entity too large' },
344
- 413,
345
- );
346
- }
347
- // Node accepts a client-supplied `requestId` (correlates the SSE events
348
- // with the client's own bookkeeping); fall back to a fresh UUID.
349
- const requestId = clientRequestId(body) ?? crypto.randomUUID();
350
- // The user object owns the turn; it returns either an SSE stream or JSON.
351
- // We pass the raw body through so attachments/tools/agActions survive.
352
- const res = await userStub(c.env, identity.userDid).fetch(
353
- `https://user-oracle/turn/${encodeURIComponent(sessionId)}`,
354
- {
355
- method: 'POST',
356
- headers: {
357
- 'content-type': 'application/json',
358
- 'x-request-id': requestId,
359
- 'x-identity': JSON.stringify(identity),
757
+ // The cap bounds the memory one request can pin (see turn-body-cap.ts); it
758
+ // is enforced before the body is read, with the status and message Node's
759
+ // body parser uses.
760
+ app.post(
761
+ '/messages/:sessionId',
762
+ limitBody(MAX_TURN_BODY_BYTES),
763
+ async (c) => {
764
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
765
+ const sessionId = c.req.param('sessionId');
766
+ const body = await c.req.text();
767
+ // Node accepts a client-supplied `requestId` (correlates the SSE events
768
+ // with the client's own bookkeeping); fall back to a fresh UUID.
769
+ const requestId = clientRequestId(body) ?? crypto.randomUUID();
770
+ // The user object owns the turn; it returns either an SSE stream or JSON.
771
+ // We pass the raw body through so attachments/tools/agActions survive.
772
+ const res = await userStub(c.env, identity.userDid).fetch(
773
+ `https://user-oracle/turn/${encodeURIComponent(sessionId)}`,
774
+ {
775
+ method: 'POST',
776
+ headers: {
777
+ 'content-type': 'application/json',
778
+ 'x-request-id': requestId,
779
+ 'x-identity': JSON.stringify(identity),
780
+ },
781
+ body,
360
782
  },
361
- body,
362
- },
363
- );
364
- const headers = new Headers(res.headers);
365
- headers.set('x-request-id', requestId);
366
- headers.set('access-control-expose-headers', 'x-request-id');
367
- return new Response(res.body, { status: res.status, headers });
368
- });
783
+ );
784
+ const headers = new Headers(res.headers);
785
+ headers.set('x-request-id', requestId);
786
+ headers.set('access-control-expose-headers', 'x-request-id');
787
+ return new Response(res.body, { status: res.status, headers });
788
+ },
789
+ );
369
790
 
370
791
  // --- delegation (room-state persisted, for header-less Matrix turns) -----------
371
- app.post('/delegation', async (c) => {
792
+ app.post('/delegation', limitBody(DELEGATION_BODY_BYTES), async (c) => {
372
793
  const auth = c.get('auth');
373
- const body = (await c.req.json().catch(() => ({}))) as {
374
- raw?: string;
375
- expiration?: number;
376
- };
794
+ const body = (await c.req.json().catch(() => ({}))) as { raw?: string };
377
795
  const raw = body.raw ?? auth.delegation;
378
796
  if (!raw) return c.json({ message: 'raw delegation is required' }, 400);
797
+ // What is stored here authorizes header-less turns long after this
798
+ // request, so it gets the header's checks: for this oracle, issued by
799
+ // the caller, with a bounded expiry — and that expiry is the token's,
800
+ // never a value the client states beside it.
801
+ const checked = await validateDelegation(raw, authConfigFromEnv(c.env));
802
+ if (!checked.ok)
803
+ return c.json(
804
+ { message: `Invalid UCAN delegation: ${checked.error}` },
805
+ 400,
806
+ );
807
+ if (checked.userDid !== auth.userDid)
808
+ return c.json(
809
+ { message: 'The delegation was not issued by the authenticated user' },
810
+ 403,
811
+ );
379
812
  const room = await gateway(c.env).resolveUserRoom(auth.userDid);
380
813
  if (!room) return c.json({ message: 'No oracle room for this user' }, 404);
381
- const expiration = body.expiration ?? auth.delegationExpiration;
814
+ const { expiration } = checked;
382
815
  // The Node runtime's `DelegationStore` record, in its compressed
383
816
  // room-state envelope, so a user can move between runtimes without
384
817
  // re-authorising (`updatedAt` is required by Node's schema).
@@ -390,7 +823,7 @@ export function createShell(
390
823
  raw,
391
824
  issuer: auth.userDid,
392
825
  audience: c.env.ORACLE_DID,
393
- ...(expiration && { expiration }),
826
+ expiration,
394
827
  updatedAt: new Date().toISOString(),
395
828
  }),
396
829
  ),
@@ -406,10 +839,7 @@ export function createShell(
406
839
  `[shell] could not push the deposited delegation to ${auth.userDid}'s object: ${err instanceof Error ? err.message : String(err)}`,
407
840
  );
408
841
  });
409
- return c.json({
410
- ok: true,
411
- expiration: body.expiration ?? auth.delegationExpiration,
412
- });
842
+ return c.json({ ok: true, expiration });
413
843
  });
414
844
  app.get('/delegation', async (c) => {
415
845
  const auth = c.get('auth');
@@ -477,6 +907,7 @@ export function createShell(
477
907
  // so every route is proxied there — same pattern as streaming turns. The
478
908
  // shell only short-circuits the disabled case so stray requests never boot
479
909
  // a Durable Object.
910
+ app.use('/byo-llm/*', limitBody(BYO_LLM_BODY_BYTES));
480
911
  app.all('/byo-llm/*', async (c) => {
481
912
  if (c.env.BYO_LLM_ENABLED !== 'true') {
482
913
  return c.req.method === 'GET' && c.req.path === '/byo-llm/status'
@@ -514,6 +945,20 @@ export function createShell(
514
945
  return c.json({ statusCode: 404, message: 'Not found' }, 404);
515
946
  return next();
516
947
  });
948
+ app.use('/debug/*', limitBody(DEBUG_BODY_BYTES));
949
+ // The gateway-wide routes act on the one gateway every user depends on
950
+ // (stop, restart, reset, rotate, every user's outbox). With
951
+ // ORACLE_OPERATOR_DIDS set only those DIDs may call them; unset, any
952
+ // authenticated caller may (local and devnet drills).
953
+ app.use('/debug/matrix/*', async (c, next) => {
954
+ const operators = operatorDids(c.env);
955
+ if (operators && !operators.has(c.get('auth').userDid))
956
+ return c.json(
957
+ { statusCode: 403, message: 'Operator routes are restricted' },
958
+ 403,
959
+ );
960
+ return next();
961
+ });
517
962
  app.get('/debug/storage', async (c) =>
518
963
  c.json(await userStub(c.env, c.get('auth').userDid).storageStatus()),
519
964
  );
@@ -533,7 +978,11 @@ export function createShell(
533
978
  );
534
979
  });
535
980
  app.post('/debug/storage/reset', async (c) =>
536
- c.json(await userStub(c.env, c.get('auth').userDid).resetWorkingCopy()),
981
+ c.json(
982
+ await userStub(c.env, c.get('auth').userDid).resetWorkingCopy(
983
+ identityOf(c.get('auth'), c.req.raw.headers),
984
+ ),
985
+ ),
537
986
  );
538
987
  app.post('/debug/object/abort', async (c) => {
539
988
  try {
@@ -665,7 +1114,15 @@ export function createShell(
665
1114
  ),
666
1115
  );
667
1116
  app.onError((err, c) => {
668
- console.error(`[shell] ${c.req.method} ${c.req.path} failed:`, err);
1117
+ // Thrown on purpose with a status and a message meant for the client.
1118
+ if (err instanceof HTTPException) return err.getResponse();
1119
+ if (err instanceof ChannelError)
1120
+ return c.json({ code: err.code, message: err.message }, err.status);
1121
+ const requestId = errorRequestId(c.req.header('x-request-id'));
1122
+ console.error(
1123
+ `[shell] ${c.req.method} ${loggedRoute(c)} failed (request ${requestId}):`,
1124
+ err,
1125
+ );
669
1126
  // A cold boot that could not read the user's owner copy changed nothing.
670
1127
  // Say precisely why: 503 + retryable for a transient store/VFS failure
671
1128
  // (the object already retried with backoff), 403 for a missing
@@ -686,9 +1143,16 @@ export function createShell(
686
1143
  failure.httpStatus,
687
1144
  );
688
1145
  }
1146
+ // Anything else may carry internals (SQL, homeserver or Blocksync error
1147
+ // text, room ids): the client gets a generic message and the id that
1148
+ // finds the details in the log.
689
1149
  return c.json(
690
- { statusCode: 500, message: err.message || 'Internal error' },
1150
+ { statusCode: 500, message: 'Internal server error', requestId },
691
1151
  500,
1152
+ {
1153
+ 'x-request-id': requestId,
1154
+ 'access-control-expose-headers': 'x-request-id',
1155
+ },
692
1156
  );
693
1157
  });
694
1158
 
@@ -697,6 +1161,11 @@ export function createShell(
697
1161
 
698
1162
  const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
699
1163
 
1164
+ /** The client's `x-request-id` when it is a sane opaque token, else a fresh one. */
1165
+ function errorRequestId(header: string | undefined): string {
1166
+ return header && REQUEST_ID_RE.test(header) ? header : crypto.randomUUID();
1167
+ }
1168
+
700
1169
  /** The body's `requestId` when it is a sane opaque token; null otherwise. */
701
1170
  function clientRequestId(rawBody: string): string | null {
702
1171
  try {
@@ -711,6 +1180,17 @@ function clientRequestId(rawBody: string): string | null {
711
1180
  return null;
712
1181
  }
713
1182
 
1183
+ /**
1184
+ * The route pattern a request matched (`/messages/:sessionId`), never the
1185
+ * concrete path: a path carries session and message ids, and a log line
1186
+ * that pairs them with the caller's DID could tie an anonymous feedback
1187
+ * submission to its author. In a middleware `c.req.routePath` is the
1188
+ * middleware's own `*`, so the handler's pattern (the last match) is used.
1189
+ */
1190
+ function loggedRoute(c: Parameters<typeof routePath>[0]): string {
1191
+ return routePath(c, -1) || '(unmatched)';
1192
+ }
1193
+
714
1194
  function identityOf(auth: AuthResult, headers: Headers): TurnIdentity {
715
1195
  return {
716
1196
  userDid: auth.userDid,