@ixo/oracle-runtime-workers 0.13.0 → 0.15.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 +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -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 +1883 -264
  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 +529 -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 };
@@ -78,18 +146,121 @@ function isAbortRejection(err: unknown): boolean {
78
146
  );
79
147
  }
80
148
 
81
- const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
149
+ /**
150
+ * Routes that answer without a UCAN invocation: the banner, liveness
151
+ * probes, the model list, and the gateway's status/start that monitors and
152
+ * the drills poll. Every one of them is read-only or idempotent; nothing
153
+ * that changes state for other users is here (the debug routes included —
154
+ * they authenticate like every other route).
155
+ */
156
+ export const BUILTIN_EXCLUSIONS: readonly RouteExclusion[] = [
82
157
  { path: '/', method: 'GET' },
83
158
  { path: '/health', method: 'ALL' },
84
159
  { path: '/health/*', method: 'ALL' },
85
160
  { path: '/models', method: 'GET' },
86
161
  { path: '/matrix/status', method: 'GET' },
87
162
  { 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
163
  ];
92
164
 
165
+ // Body caps of the built-in routes (a turn's is `MAX_TURN_BODY_BYTES`).
166
+ /** `POST /messages/abort`: a session id. */
167
+ export const ABORT_BODY_BYTES = 4 * 1024;
168
+ /** `POST /delegation`: one serialized delegation (a base64 CAR with its proof chain). */
169
+ export const DELEGATION_BODY_BYTES = 64 * 1024;
170
+ /** `/byo-llm/*`: provider credentials and OAuth exchanges. */
171
+ export const BYO_LLM_BODY_BYTES = 64 * 1024;
172
+ /** `/debug/*`: operator drills (the largest is one Matrix event, itself capped at 64 KiB). */
173
+ export const DEBUG_BODY_BYTES = 64 * 1024;
174
+
175
+ /** A body cap enforced before the body is buffered: over it, 413 and the handler never runs. */
176
+ function limitBody(maxSize: number) {
177
+ return bodyLimit({
178
+ maxSize,
179
+ onError: (c) =>
180
+ c.json({ statusCode: 413, message: 'request entity too large' }, 413),
181
+ });
182
+ }
183
+
184
+ const DID_SEGMENT = String.raw`(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+`;
185
+ /** A W3C DID (`did:<method>:<method-specific-id>`), bounded in length. */
186
+ const DID_RE = new RegExp(`^did:[a-z0-9]+:${DID_SEGMENT}(?::${DID_SEGMENT})*$`);
187
+ const MAX_DID_LENGTH = 512;
188
+
189
+ export function isDidShaped(value: string): boolean {
190
+ return value.length <= MAX_DID_LENGTH && DID_RE.test(value);
191
+ }
192
+
193
+ /**
194
+ * The key of one rate-limit budget. Prefixed with the oracle's DID: rate
195
+ * limiter bindings that share a `namespace_id` share their counters, even
196
+ * across Workers, so two oracles on one account would otherwise share every
197
+ * user's budget.
198
+ */
199
+ export function rateLimitKey(
200
+ env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
201
+ scope: 'user' | 'artifact' | 'socket' | 'status' | 'start',
202
+ subject: string,
203
+ ): string {
204
+ return `${env.ORACLE_DID}|${scope}|${subject}`;
205
+ }
206
+
207
+ /**
208
+ * Whether an unauthenticated caller (keyed by its IP) is over its budget.
209
+ * No binding or no client IP (local runs, tests): never limited.
210
+ */
211
+ async function ipOverLimit(
212
+ env: OracleWorkerEnv,
213
+ scope: 'artifact' | 'socket' | 'status' | 'start',
214
+ ip: string | undefined,
215
+ ): Promise<boolean> {
216
+ if (!ip || !env.RATE_LIMIT) return false;
217
+ const { success } = await env.RATE_LIMIT.limit({
218
+ key: rateLimitKey(env, scope, ip),
219
+ });
220
+ return !success;
221
+ }
222
+
223
+ /**
224
+ * The operators allowed onto the gateway-wide debug routes
225
+ * (`ORACLE_OPERATOR_DIDS`, comma-separated DIDs): null when the variable is
226
+ * unset, an empty set when it is malformed (fail closed — nobody, and an
227
+ * error in the log). Parsed once per distinct value.
228
+ */
229
+ let operatorList: { raw: string; dids: ReadonlySet<string> } | null = null;
230
+
231
+ function operatorDids(env: OracleWorkerEnv): ReadonlySet<string> | null {
232
+ const raw = env.ORACLE_OPERATOR_DIDS;
233
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
234
+ if (operatorList?.raw === raw) return operatorList.dids;
235
+ const entries = raw
236
+ .split(',')
237
+ .map((did) => did.trim())
238
+ .filter(Boolean);
239
+ const malformed = entries.filter((did) => !isDidShaped(did));
240
+ if (malformed.length > 0)
241
+ console.error(
242
+ `[shell] ORACLE_OPERATOR_DIDS has entries that are not DIDs (${malformed.join(', ')}); no caller is let onto /debug/matrix/*`,
243
+ );
244
+ operatorList = {
245
+ raw,
246
+ dids: new Set(malformed.length > 0 ? [] : entries),
247
+ };
248
+ return operatorList.dids;
249
+ }
250
+
251
+ /** What `GET /health/matrix` answers: the gateway's in-memory `running` flag. */
252
+ async function gatewayRunning(env: OracleWorkerEnv): Promise<boolean> {
253
+ const res = await gateway(env).fetch('https://matrix-gateway/health');
254
+ if (!res.ok) return false;
255
+ const body: unknown = await res.json();
256
+ return (
257
+ typeof body === 'object' &&
258
+ body !== null &&
259
+ 'running' in body &&
260
+ body.running === true
261
+ );
262
+ }
263
+
93
264
  export function createShell(
94
265
  opts: ShellOptions = {},
95
266
  ): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
@@ -106,6 +277,9 @@ export function createShell(
106
277
  // — the shell only routes on `?userDid`, and the object refuses a token
107
278
  // that does not belong to that DID. The 101 response must also reach the
108
279
  // client untouched (no CORS header rewriting on an upgraded response).
280
+ // Unauthenticated until CONNECT, so before any object is addressed the
281
+ // DID must at least look like one and the caller's IP must be within its
282
+ // budget: each upgrade costs a user object a request and a held socket.
109
283
  app.all('/socket.io/*', async (c) => {
110
284
  const userDid = c.req.query('userDid');
111
285
  if (!userDid) {
@@ -114,6 +288,12 @@ export function createShell(
114
288
  400,
115
289
  );
116
290
  }
291
+ if (!isDidShaped(userDid)) {
292
+ return c.json(
293
+ { code: 3, message: 'userDid query parameter is not a DID.' },
294
+ 400,
295
+ );
296
+ }
117
297
  if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
118
298
  return c.json(
119
299
  {
@@ -124,6 +304,8 @@ export function createShell(
124
304
  426,
125
305
  );
126
306
  }
307
+ if (await ipOverLimit(c.env, 'socket', c.req.header('cf-connecting-ip')))
308
+ return c.json({ code: 3, message: 'Too many requests.' }, 429);
127
309
  const target = new URL(c.req.url);
128
310
  target.protocol = 'https:';
129
311
  target.host = 'user-oracle';
@@ -132,6 +314,22 @@ export function createShell(
132
314
  return userStub(c.env, userDid).fetch(forwarded);
133
315
  });
134
316
 
317
+ // --- artefact links (public) -----------------------------------------------
318
+ // Also ahead of CORS and auth: a link is opened by whoever holds it, and
319
+ // the data route answers any origin itself (a shared viewer on another
320
+ // host fetches it). Both serve only what is safe without the key, which
321
+ // stays in the link's fragment: a static page and ciphertext.
322
+ app.get('/a/:artifactId', (c) =>
323
+ artifactPageResponse(c.env, c.req.param('artifactId')),
324
+ );
325
+ app.get('/a/:artifactId/data', async (c) => {
326
+ if (await ipOverLimit(c.env, 'artifact', c.req.header('cf-connecting-ip')))
327
+ return c.text('Too many requests', 429, {
328
+ 'access-control-allow-origin': '*',
329
+ });
330
+ return artifactDataResponse(c.env, c.req.param('artifactId'));
331
+ });
332
+
135
333
  app.use('*', async (c, next) => {
136
334
  const origin = c.env.CORS_ORIGIN ?? '*';
137
335
  return cors({
@@ -161,43 +359,118 @@ export function createShell(
161
359
  timestamp: new Date().toISOString(),
162
360
  }),
163
361
  );
362
+ // `frontendTools` is the frontend bridge version: the Portal enables
363
+ // conversational writes only against a runtime that advertises it. A
364
+ // version signal, not proof the deployment behaves (see frontend-bridge.md).
164
365
  app.get('/health', (c) =>
165
- c.json({ status: 'ok', timestamp: new Date().toISOString() }),
366
+ c.json({
367
+ status: 'ok',
368
+ timestamp: new Date().toISOString(),
369
+ frontendTools: FRONTEND_BRIDGE,
370
+ }),
166
371
  );
372
+ // A liveness probe: the gateway's in-memory flag, no storage read. The
373
+ // full picture is `/matrix/status`.
167
374
  app.get('/health/matrix', async (c) => {
168
- const status = await gateway(c.env).status();
169
- return c.json(status, status.running ? 200 : 503);
375
+ const running = await gatewayRunning(c.env).catch((err: unknown) => {
376
+ console.warn(`[shell] gateway health check failed: ${errorText(err)}`);
377
+ return false;
378
+ });
379
+ return c.json({ running }, running ? 200 : 503);
170
380
  });
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)) ?? {
381
+ // Public (monitors and the drills poll it), so limited per IP; the
382
+ // gateway serves the storage-scanning part from a short cache.
383
+ app.get('/matrix/status', async (c) => {
384
+ if (await ipOverLimit(c.env, 'status', c.req.header('cf-connecting-ip')))
385
+ return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
386
+ return c.json(await gateway(c.env).status());
387
+ });
388
+ app.post('/matrix/start', async (c) => {
389
+ if (await ipOverLimit(c.env, 'start', c.req.header('cf-connecting-ip')))
390
+ return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
391
+ return c.json(await gateway(c.env).ensureStarted());
392
+ });
393
+ app.get('/models', async (c) => {
394
+ const waitUntil = executionWaitUntil(c);
395
+ return c.json(
396
+ (await opts.listModels?.(c.env, waitUntil ? { waitUntil } : {})) ?? {
178
397
  models: [],
179
398
  // Node's `ModelListing` shape (`{ models, default }`); the client SDK
180
399
  // reads `default`.
181
400
  default: c.env.DEFAULT_MODEL ?? null,
182
401
  },
183
- ),
184
- );
402
+ );
403
+ });
404
+
405
+ app.post('/channels/turn', async (c) => {
406
+ try {
407
+ if (!c.env.CHANNEL_SERVICE_DID)
408
+ return c.json({ message: 'Channels are not configured' }, 503);
409
+ const raw = await readChannelBody(c.req.raw);
410
+ let json: unknown;
411
+ try {
412
+ json = JSON.parse(raw);
413
+ } catch {
414
+ throw new ChannelError(400, 'Invalid JSON request');
415
+ }
416
+ const parsed = ChannelTurnBody.safeParse(json);
417
+ if (!parsed.success) throw new ChannelError(400, 'Invalid channel turn');
418
+ const identity = await authenticateChannel(
419
+ c.req.raw.headers,
420
+ raw,
421
+ parsed.data,
422
+ channelAuthConfig(c.env),
423
+ );
424
+ // Limited as soon as the user is proven, before the Auth Hub round
425
+ // trip: a flood or a tight poll must not cost one call there each.
426
+ if (
427
+ c.env.RATE_LIMIT &&
428
+ !(
429
+ await c.env.RATE_LIMIT.limit({
430
+ key: rateLimitKey(c.env, 'user', identity.userDid),
431
+ })
432
+ ).success
433
+ )
434
+ throw new ChannelError(429, 'Too many requests');
435
+ await assertActiveChannelBinding(identity, c.env);
436
+ const outcome = await userStub(c.env, identity.userDid).channelTurn(
437
+ identity,
438
+ parsed.data,
439
+ await channelRequestHash(raw),
440
+ );
441
+ if (!outcome.ok)
442
+ return c.json({ message: outcome.message }, outcome.status);
443
+ const result = outcome.result;
444
+ return c.json(
445
+ result,
446
+ ['queued', 'running', 'recovering'].includes(result.status) ? 202 : 200,
447
+ );
448
+ } catch (error) {
449
+ if (error instanceof ChannelError)
450
+ return c.json({ message: error.message }, error.status);
451
+ return c.json(
452
+ { message: 'Channel turn is temporarily unavailable' },
453
+ 503,
454
+ );
455
+ }
456
+ });
185
457
 
186
458
  // --- auth ------------------------------------------------------------------
187
459
  app.use('*', async (c, next) => {
188
460
  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
- });
461
+ const outcome = await authenticate(
462
+ c.req.raw.headers,
463
+ authConfigFromEnv(c.env),
464
+ );
196
465
  if (!outcome.ok)
197
466
  return c.json(
198
467
  { statusCode: outcome.status, message: outcome.error },
199
468
  outcome.status as 401,
200
469
  );
470
+ if (outcome.auth.via === 'delegation')
471
+ console.warn(
472
+ `[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`,
473
+ );
201
474
  c.set('auth', outcome.auth);
202
475
  return next();
203
476
  });
@@ -206,15 +479,17 @@ export function createShell(
206
479
  // Cloudflare's native limiter; the limit/period live on the binding in
207
480
  // wrangler.jsonc. Keyed by DID rather than IP so co-located users (one
208
481
  // 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).
482
+ // unbounded LLM spend; the key carries the oracle's DID (`rateLimitKey`).
483
+ // Auth-excluded routes (health/models/matrix status) ran `next()` above
484
+ // and never reach here. Absent binding = no limiting (local harness / tests).
212
485
  app.use('*', async (c, next) => {
213
486
  const limiter = c.env.RATE_LIMIT;
214
487
  if (!limiter) return next();
215
488
  const auth = c.get('auth') as AuthResult | undefined;
216
489
  if (!auth) return next();
217
- const { success } = await limiter.limit({ key: auth.userDid });
490
+ const { success } = await limiter.limit({
491
+ key: rateLimitKey(c.env, 'user', auth.userDid),
492
+ });
218
493
  if (!success)
219
494
  return c.json(
220
495
  {
@@ -261,8 +536,89 @@ export function createShell(
261
536
  : c.json({ message: 'Session not found' }, 404);
262
537
  });
263
538
 
539
+ app.use('/topic-deliverables/*', async (c, next) => {
540
+ if (c.env.TOPIC_DELIVERABLES_ENABLED !== 'true')
541
+ return c.json({ message: 'Not found.' }, 404);
542
+ if (c.get('auth')?.via !== 'invocation')
543
+ return c.json({ message: 'A signed invocation is required.' }, 401);
544
+ return next();
545
+ });
546
+ app.use(
547
+ '/topic-deliverables/*',
548
+ bodyLimit({
549
+ maxSize: TOPIC_DELIVERABLE_BODY_BYTES,
550
+ onError: (c) => c.json({ message: 'Request is too large.' }, 413),
551
+ }),
552
+ );
553
+ app.on(
554
+ ['PUT', 'GET', 'POST'],
555
+ [
556
+ '/topic-deliverables/:operationId',
557
+ '/topic-deliverables/:operationId/cancel',
558
+ ],
559
+ async (c) => {
560
+ const cancelling =
561
+ c.req.routePath === '/topic-deliverables/:operationId/cancel';
562
+ if (cancelling !== (c.req.method === 'POST'))
563
+ return c.json({ message: 'Not found.' }, 404);
564
+ const operationId = TopicOperationId.safeParse(
565
+ c.req.param('operationId'),
566
+ );
567
+ if (!operationId.success)
568
+ return c.json({ message: 'Invalid operation ID.' }, 400);
569
+ let command: TopicDeliverableCommand;
570
+ if (c.req.method === 'PUT' || cancelling) {
571
+ const parsed = TopicDeliverableRequestSchema.safeParse(
572
+ await c.req.json().catch(() => null),
573
+ );
574
+ if (!parsed.success)
575
+ return c.json({ message: 'Invalid deliverable request.' }, 400);
576
+ command = {
577
+ action: cancelling ? 'cancel' : 'start',
578
+ request: parsed.data,
579
+ };
580
+ } else {
581
+ command = { action: 'read' };
582
+ }
583
+ const auth = c.get('auth');
584
+ const result = await userStub(c.env, auth.userDid).topicDeliverable(
585
+ identityOf(auth, c.req.raw.headers),
586
+ operationId.data,
587
+ command,
588
+ );
589
+ return result.ok
590
+ ? c.json(result.snapshot)
591
+ : c.json({ message: result.message }, result.status);
592
+ },
593
+ );
594
+
595
+ // --- artefacts (owner) ---------------------------------------------------------
596
+ app.get('/artifacts/:artifactId', async (c) => {
597
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
598
+ const artifactId = c.req.param('artifactId');
599
+ const artifact = ARTIFACT_ID_RE.test(artifactId)
600
+ ? await userStub(c.env, identity.userDid).artifact(identity, artifactId)
601
+ : null;
602
+ return artifact
603
+ ? c.json(artifact)
604
+ : c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
605
+ });
606
+ app.delete('/artifacts/:artifactId', async (c) => {
607
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
608
+ const artifactId = c.req.param('artifactId');
609
+ const revoked =
610
+ ARTIFACT_ID_RE.test(artifactId) &&
611
+ (await userStub(c.env, identity.userDid).revokeArtifact(
612
+ identity,
613
+ artifactId,
614
+ ));
615
+ return revoked
616
+ ? c.json({ revoked: true })
617
+ : c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
618
+ });
619
+
264
620
  // --- messages ----------------------------------------------------------------
265
- app.post('/messages/abort', async (c) => {
621
+ app.post('/messages/abort', limitBody(ABORT_BODY_BYTES), async (c) => {
266
622
  const identity = identityOf(c.get('auth'), c.req.raw.headers);
267
623
  const body = (await c.req.json().catch(() => ({}))) as {
268
624
  sessionId?: string;
@@ -280,10 +636,43 @@ export function createShell(
280
636
  identity,
281
637
  c.req.param('sessionId'),
282
638
  );
283
- return new Response(`{"messages":${messages}}`, {
284
- headers: { 'content-type': 'application/json' },
285
- });
639
+ return new Response(
640
+ withFeedbackCapability(`{"messages":${messages}}`, !!opts.feedback),
641
+ { headers: { 'content-type': 'application/json' } },
642
+ );
286
643
  });
644
+ // Anonymous feedback on one completed Agent reply (src/feedback/submit.ts).
645
+ app.post(
646
+ '/messages/:sessionId/:messageId/feedback',
647
+ bodyLimit({
648
+ maxSize: FEEDBACK_BODY_MAX_BYTES,
649
+ onError: (c) =>
650
+ c.json({ statusCode: 413, message: 'request entity too large' }, 413),
651
+ }),
652
+ async (c) => {
653
+ if (!opts.feedback) {
654
+ const off = feedbackFailure(
655
+ 404,
656
+ 'FEEDBACK_DISABLED',
657
+ 'Anonymous feedback is not enabled on this oracle',
658
+ );
659
+ return c.json(off.body, off.status);
660
+ }
661
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
662
+ const result = await submitFeedback({
663
+ options: opts.feedback,
664
+ user: userStub(c.env, identity.userDid),
665
+ identity,
666
+ sessionId: c.req.param('sessionId'),
667
+ messageId: c.req.param('messageId'),
668
+ body: await c.req.json<unknown>().catch(() => undefined),
669
+ clientIp: c.req.header('cf-connecting-ip'),
670
+ ipLimiter: c.env.FEEDBACK_RATE_LIMIT ?? c.env.RATE_LIMIT,
671
+ log: console,
672
+ });
673
+ return c.json(result.body, result.status);
674
+ },
675
+ );
287
676
  // One turn-aligned page of the transcript (docs/plans/transcript-paging.md):
288
677
  // the newest `limit` turns, the turns `before=` a cursor, or what came
289
678
  // `after=` one. The legacy listing above stays the whole transcript.
@@ -299,7 +688,7 @@ export function createShell(
299
688
  );
300
689
  if (!page.ok)
301
690
  return c.json({ statusCode: page.status, message: page.message }, 400);
302
- return new Response(page.json, {
691
+ return new Response(withFeedbackCapability(page.json, !!opts.feedback), {
303
692
  headers: { 'content-type': 'application/json' },
304
693
  });
305
694
  });
@@ -332,53 +721,64 @@ export function createShell(
332
721
  );
333
722
  return new Response(res.body, { status: res.status, headers });
334
723
  });
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),
724
+ // The cap bounds the memory one request can pin (see turn-body-cap.ts); it
725
+ // is enforced before the body is read, with the status and message Node's
726
+ // body parser uses.
727
+ app.post(
728
+ '/messages/:sessionId',
729
+ limitBody(MAX_TURN_BODY_BYTES),
730
+ async (c) => {
731
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
732
+ const sessionId = c.req.param('sessionId');
733
+ const body = await c.req.text();
734
+ // Node accepts a client-supplied `requestId` (correlates the SSE events
735
+ // with the client's own bookkeeping); fall back to a fresh UUID.
736
+ const requestId = clientRequestId(body) ?? crypto.randomUUID();
737
+ // The user object owns the turn; it returns either an SSE stream or JSON.
738
+ // We pass the raw body through so attachments/tools/agActions survive.
739
+ const res = await userStub(c.env, identity.userDid).fetch(
740
+ `https://user-oracle/turn/${encodeURIComponent(sessionId)}`,
741
+ {
742
+ method: 'POST',
743
+ headers: {
744
+ 'content-type': 'application/json',
745
+ 'x-request-id': requestId,
746
+ 'x-identity': JSON.stringify(identity),
747
+ },
748
+ body,
360
749
  },
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
- });
750
+ );
751
+ const headers = new Headers(res.headers);
752
+ headers.set('x-request-id', requestId);
753
+ headers.set('access-control-expose-headers', 'x-request-id');
754
+ return new Response(res.body, { status: res.status, headers });
755
+ },
756
+ );
369
757
 
370
758
  // --- delegation (room-state persisted, for header-less Matrix turns) -----------
371
- app.post('/delegation', async (c) => {
759
+ app.post('/delegation', limitBody(DELEGATION_BODY_BYTES), async (c) => {
372
760
  const auth = c.get('auth');
373
- const body = (await c.req.json().catch(() => ({}))) as {
374
- raw?: string;
375
- expiration?: number;
376
- };
761
+ const body = (await c.req.json().catch(() => ({}))) as { raw?: string };
377
762
  const raw = body.raw ?? auth.delegation;
378
763
  if (!raw) return c.json({ message: 'raw delegation is required' }, 400);
764
+ // What is stored here authorizes header-less turns long after this
765
+ // request, so it gets the header's checks: for this oracle, issued by
766
+ // the caller, with a bounded expiry — and that expiry is the token's,
767
+ // never a value the client states beside it.
768
+ const checked = await validateDelegation(raw, authConfigFromEnv(c.env));
769
+ if (!checked.ok)
770
+ return c.json(
771
+ { message: `Invalid UCAN delegation: ${checked.error}` },
772
+ 400,
773
+ );
774
+ if (checked.userDid !== auth.userDid)
775
+ return c.json(
776
+ { message: 'The delegation was not issued by the authenticated user' },
777
+ 403,
778
+ );
379
779
  const room = await gateway(c.env).resolveUserRoom(auth.userDid);
380
780
  if (!room) return c.json({ message: 'No oracle room for this user' }, 404);
381
- const expiration = body.expiration ?? auth.delegationExpiration;
781
+ const { expiration } = checked;
382
782
  // The Node runtime's `DelegationStore` record, in its compressed
383
783
  // room-state envelope, so a user can move between runtimes without
384
784
  // re-authorising (`updatedAt` is required by Node's schema).
@@ -390,7 +790,7 @@ export function createShell(
390
790
  raw,
391
791
  issuer: auth.userDid,
392
792
  audience: c.env.ORACLE_DID,
393
- ...(expiration && { expiration }),
793
+ expiration,
394
794
  updatedAt: new Date().toISOString(),
395
795
  }),
396
796
  ),
@@ -406,10 +806,7 @@ export function createShell(
406
806
  `[shell] could not push the deposited delegation to ${auth.userDid}'s object: ${err instanceof Error ? err.message : String(err)}`,
407
807
  );
408
808
  });
409
- return c.json({
410
- ok: true,
411
- expiration: body.expiration ?? auth.delegationExpiration,
412
- });
809
+ return c.json({ ok: true, expiration });
413
810
  });
414
811
  app.get('/delegation', async (c) => {
415
812
  const auth = c.get('auth');
@@ -477,6 +874,7 @@ export function createShell(
477
874
  // so every route is proxied there — same pattern as streaming turns. The
478
875
  // shell only short-circuits the disabled case so stray requests never boot
479
876
  // a Durable Object.
877
+ app.use('/byo-llm/*', limitBody(BYO_LLM_BODY_BYTES));
480
878
  app.all('/byo-llm/*', async (c) => {
481
879
  if (c.env.BYO_LLM_ENABLED !== 'true') {
482
880
  return c.req.method === 'GET' && c.req.path === '/byo-llm/status'
@@ -514,6 +912,20 @@ export function createShell(
514
912
  return c.json({ statusCode: 404, message: 'Not found' }, 404);
515
913
  return next();
516
914
  });
915
+ app.use('/debug/*', limitBody(DEBUG_BODY_BYTES));
916
+ // The gateway-wide routes act on the one gateway every user depends on
917
+ // (stop, restart, reset, rotate, every user's outbox). With
918
+ // ORACLE_OPERATOR_DIDS set only those DIDs may call them; unset, any
919
+ // authenticated caller may (local and devnet drills).
920
+ app.use('/debug/matrix/*', async (c, next) => {
921
+ const operators = operatorDids(c.env);
922
+ if (operators && !operators.has(c.get('auth').userDid))
923
+ return c.json(
924
+ { statusCode: 403, message: 'Operator routes are restricted' },
925
+ 403,
926
+ );
927
+ return next();
928
+ });
517
929
  app.get('/debug/storage', async (c) =>
518
930
  c.json(await userStub(c.env, c.get('auth').userDid).storageStatus()),
519
931
  );
@@ -533,7 +945,11 @@ export function createShell(
533
945
  );
534
946
  });
535
947
  app.post('/debug/storage/reset', async (c) =>
536
- c.json(await userStub(c.env, c.get('auth').userDid).resetWorkingCopy()),
948
+ c.json(
949
+ await userStub(c.env, c.get('auth').userDid).resetWorkingCopy(
950
+ identityOf(c.get('auth'), c.req.raw.headers),
951
+ ),
952
+ ),
537
953
  );
538
954
  app.post('/debug/object/abort', async (c) => {
539
955
  try {
@@ -665,7 +1081,15 @@ export function createShell(
665
1081
  ),
666
1082
  );
667
1083
  app.onError((err, c) => {
668
- console.error(`[shell] ${c.req.method} ${c.req.path} failed:`, err);
1084
+ // Thrown on purpose with a status and a message meant for the client.
1085
+ if (err instanceof HTTPException) return err.getResponse();
1086
+ if (err instanceof ChannelError)
1087
+ return c.json({ message: err.message }, err.status);
1088
+ const requestId = errorRequestId(c.req.header('x-request-id'));
1089
+ console.error(
1090
+ `[shell] ${c.req.method} ${loggedRoute(c)} failed (request ${requestId}):`,
1091
+ err,
1092
+ );
669
1093
  // A cold boot that could not read the user's owner copy changed nothing.
670
1094
  // Say precisely why: 503 + retryable for a transient store/VFS failure
671
1095
  // (the object already retried with backoff), 403 for a missing
@@ -686,9 +1110,16 @@ export function createShell(
686
1110
  failure.httpStatus,
687
1111
  );
688
1112
  }
1113
+ // Anything else may carry internals (SQL, homeserver or Blocksync error
1114
+ // text, room ids): the client gets a generic message and the id that
1115
+ // finds the details in the log.
689
1116
  return c.json(
690
- { statusCode: 500, message: err.message || 'Internal error' },
1117
+ { statusCode: 500, message: 'Internal server error', requestId },
691
1118
  500,
1119
+ {
1120
+ 'x-request-id': requestId,
1121
+ 'access-control-expose-headers': 'x-request-id',
1122
+ },
692
1123
  );
693
1124
  });
694
1125
 
@@ -697,6 +1128,11 @@ export function createShell(
697
1128
 
698
1129
  const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
699
1130
 
1131
+ /** The client's `x-request-id` when it is a sane opaque token, else a fresh one. */
1132
+ function errorRequestId(header: string | undefined): string {
1133
+ return header && REQUEST_ID_RE.test(header) ? header : crypto.randomUUID();
1134
+ }
1135
+
700
1136
  /** The body's `requestId` when it is a sane opaque token; null otherwise. */
701
1137
  function clientRequestId(rawBody: string): string | null {
702
1138
  try {
@@ -711,6 +1147,17 @@ function clientRequestId(rawBody: string): string | null {
711
1147
  return null;
712
1148
  }
713
1149
 
1150
+ /**
1151
+ * The route pattern a request matched (`/messages/:sessionId`), never the
1152
+ * concrete path: a path carries session and message ids, and a log line
1153
+ * that pairs them with the caller's DID could tie an anonymous feedback
1154
+ * submission to its author. In a middleware `c.req.routePath` is the
1155
+ * middleware's own `*`, so the handler's pattern (the last match) is used.
1156
+ */
1157
+ function loggedRoute(c: Parameters<typeof routePath>[0]): string {
1158
+ return routePath(c, -1) || '(unmatched)';
1159
+ }
1160
+
714
1161
  function identityOf(auth: AuthResult, headers: Headers): TurnIdentity {
715
1162
  return {
716
1163
  userDid: auth.userDid,