@ixo/oracle-runtime-workers 0.15.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.
package/README.md CHANGED
@@ -91,7 +91,7 @@ Every variable, binding and migration is explained in
91
91
  | `POST/GET /sessions`, `POST/GET /messages/:id` | The chat API |
92
92
  | `GET /runs/:runId`, `GET /sessions/:id/messages` | Re-join a durable run; one page of a transcript |
93
93
  | `/delegation`, `/models`, `/socket.io/`, `/byo-llm/*` | Delegation, models, realtime channel, BYO LLM |
94
- | `POST /channels/turn` | IXO Channels ingress (its own channel UCAN policy) |
94
+ | `POST /channels/turn` | IXO Channels ingress; refusals carry a `code` |
95
95
  | `POST /messages/:sessionId/:messageId/feedback` | Anonymous feedback on a reply (when configured) |
96
96
  | `/topic-deliverables/:operationId` | Owner-only Topic deliverables (when enabled) |
97
97
  | `GET /a/:id`, `GET /a/:id/data` | Artefact links opened from chat apps (public) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -57,8 +57,8 @@
57
57
  "yjs": "13.6.32",
58
58
  "zod": "^4.4.3",
59
59
  "@ixo/common": "1.6.0",
60
- "@ixo/ixo-transaction": "0.1.0",
61
- "@ixo/ucan": "2.2.1"
60
+ "@ixo/ucan": "2.2.1",
61
+ "@ixo/ixo-transaction": "0.1.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -58,7 +58,11 @@ export async function authenticateChannel(
58
58
  token.length > 32_000 ||
59
59
  headers.get('x-auth-type')?.toLowerCase() !== 'ucan'
60
60
  )
61
- throw new ChannelError(401, 'A channel UCAN invocation is required');
61
+ throw new ChannelError(
62
+ 401,
63
+ 'A channel UCAN invocation is required',
64
+ 'unauthorized',
65
+ );
62
66
  const validator = await createUCANValidator({
63
67
  serverDid: config.oracleDid,
64
68
  rootIssuers: ['*'],
@@ -88,7 +92,11 @@ export async function authenticateChannel(
88
92
  chain[1] !== config.channelDid ||
89
93
  result.invoker !== config.channelDid
90
94
  )
91
- throw new ChannelError(401, 'Invalid user-rooted channel invocation');
95
+ throw new ChannelError(
96
+ 401,
97
+ 'Invalid user-rooted channel invocation',
98
+ 'unauthorized',
99
+ );
92
100
  const now = Math.floor(Date.now() / 1000);
93
101
  const invocation = await parseDelegation(token);
94
102
  const proof = invocation.proofs[0];
@@ -109,6 +117,7 @@ export async function authenticateChannel(
109
117
  throw new ChannelError(
110
118
  401,
111
119
  'A bounded, direct user channel grant is required',
120
+ 'unauthorized',
112
121
  );
113
122
  if (
114
123
  !result.expiration ||
@@ -121,6 +130,7 @@ export async function authenticateChannel(
121
130
  throw new ChannelError(
122
131
  401,
123
132
  'Channel invocations must expire within 60 seconds',
133
+ 'unauthorized',
124
134
  );
125
135
  const nb = result.capability?.nb;
126
136
  if (
@@ -130,7 +140,11 @@ export async function authenticateChannel(
130
140
  nb.bindingRevision !== input.bindingRevision ||
131
141
  nb.oracleDid !== config.oracleDid
132
142
  )
133
- throw new ChannelError(403, 'Channel scope does not match this request');
143
+ throw new ChannelError(
144
+ 403,
145
+ 'Channel scope does not match this request',
146
+ 'scope_mismatch',
147
+ );
134
148
  const hash = await channelRequestHash(raw);
135
149
  if (
136
150
  result.facts?.length !== 1 ||
@@ -140,6 +154,7 @@ export async function authenticateChannel(
140
154
  throw new ChannelError(
141
155
  403,
142
156
  'Invocation does not authorize this request body',
157
+ 'scope_mismatch',
143
158
  );
144
159
  return {
145
160
  userDid,
@@ -168,7 +183,11 @@ export function channelAuthConfig(
168
183
  >,
169
184
  ): ChannelAuthConfig {
170
185
  if (!env.CHANNEL_SERVICE_DID)
171
- throw new ChannelError(503, 'Channels are not configured');
186
+ throw new ChannelError(
187
+ 503,
188
+ 'Channels are not configured',
189
+ 'not_configured',
190
+ );
172
191
  const resolver = createCompositeDIDResolver([
173
192
  sharedIxoDIDResolver(env.BLOCKSYNC_GRAPHQL_URL),
174
193
  channelWebResolver,
@@ -204,15 +223,23 @@ export async function assertActiveChannelBinding(
204
223
  >,
205
224
  ): Promise<void> {
206
225
  if (!identity.channel)
207
- throw new ChannelError(403, 'Channel identity is required');
226
+ throw new ChannelError(
227
+ 403,
228
+ 'Channel identity is required',
229
+ 'identity_required',
230
+ );
208
231
  if (!env.AUTH_HUB_CHANNEL_SERVICE_KEY || (!env.AUTH_HUB && !env.AUTH_HUB_URL))
209
- throw new ChannelError(503, 'Channel binding validation is not configured');
232
+ throw new ChannelError(
233
+ 503,
234
+ 'Channel binding validation is not configured',
235
+ 'not_configured',
236
+ );
210
237
  const url = new URL(
211
238
  '/api/internal/channels/validate-binding',
212
239
  env.AUTH_HUB_URL ?? 'https://auth-hub.internal',
213
240
  );
214
241
  if (url.protocol !== 'https:')
215
- throw new ChannelError(503, 'Auth Hub requires HTTPS');
242
+ throw new ChannelError(503, 'Auth Hub requires HTTPS', 'not_configured');
216
243
  const request = new Request(url, {
217
244
  method: 'POST',
218
245
  headers: {
@@ -235,15 +262,31 @@ export async function assertActiveChannelBinding(
235
262
  ? env.AUTH_HUB.fetch(request)
236
263
  : fetch(request));
237
264
  } catch {
238
- throw new ChannelError(503, 'Channel binding validation is unavailable');
265
+ throw new ChannelError(
266
+ 503,
267
+ 'Channel binding validation is unavailable',
268
+ 'binding_check_unavailable',
269
+ );
239
270
  }
240
271
  if (!response.ok)
241
- throw new ChannelError(503, 'Channel binding validation is unavailable');
272
+ throw new ChannelError(
273
+ 503,
274
+ 'Channel binding validation is unavailable',
275
+ 'binding_check_unavailable',
276
+ );
242
277
  const parsed = BindingVerdict.safeParse(await response.json());
243
278
  if (!parsed.success)
244
- throw new ChannelError(503, 'Invalid channel binding validation response');
279
+ throw new ChannelError(
280
+ 503,
281
+ 'Invalid channel binding validation response',
282
+ 'binding_check_unavailable',
283
+ );
245
284
  if (!parsed.data.active)
246
- throw new ChannelError(403, 'Channel binding is inactive');
285
+ throw new ChannelError(
286
+ 403,
287
+ 'Channel binding is inactive',
288
+ 'binding_inactive',
289
+ );
247
290
  }
248
291
 
249
292
  /**
@@ -66,34 +66,130 @@ export interface ChannelTurnResponse {
66
66
  plan?: ReplyPlan;
67
67
  }
68
68
 
69
+ export type ChannelErrorStatus =
70
+ | 400
71
+ | 401
72
+ | 403
73
+ | 404
74
+ | 409
75
+ | 410
76
+ | 413
77
+ | 428
78
+ | 429
79
+ | 503;
80
+
81
+ /**
82
+ * The machine-readable reason a channel turn was refused. The gateway maps
83
+ * a refusal by `code` first and by HTTP status second. 428 (Precondition
84
+ * Required) means a precondition on the user's side is missing: fix it
85
+ * (authorize the oracle again, finish setting up the Companion room) and
86
+ * retry; it is never a transient outage.
87
+ *
88
+ * | code | status | meaning |
89
+ * | --------------------------------- | ------ | ----------------------------------------------------- |
90
+ * | `invalid_request` | 400 | body missing, not UTF-8, not JSON, or the wrong shape |
91
+ * | `unauthorized` | 401 | no valid user-rooted channel invocation |
92
+ * | `scope_mismatch` | 403 | the invocation does not cover this binding or body |
93
+ * | `identity_mismatch` | 403 | the channel identity does not match the request |
94
+ * | `identity_required` | 403 | the binding check got no channel identity |
95
+ * | `binding_inactive` | 403 | the Auth Hub reports the binding inactive |
96
+ * | `channel_grant_not_tool_authority`| 403 | a channel request carried a delegation header |
97
+ * | `session_not_found` | 404 | the session is not the user's Companion session |
98
+ * | `request_conflict` | 409 | the request id already belongs to another message |
99
+ * | `session_conflict` | 409 | the channel is already bound to another session |
100
+ * | `room_not_encrypted` | 409 | the Companion room is not end-to-end encrypted |
101
+ * | `response_expired` | 410 | the run was pruned; the request cannot run again |
102
+ * | `request_too_large` | 413 | the body is over 64 kB |
103
+ * | `room_not_ready` | 428 | the user has no Companion room yet |
104
+ * | `delegation_required` | 428 | no usable delegation to this oracle (see below) |
105
+ * | `rate_limited` | 429 | too many requests for this user |
106
+ * | `not_configured` | 503 | channels or binding validation are not configured |
107
+ * | `binding_check_unavailable` | 503 | the Auth Hub binding check failed or answered badly |
108
+ * | `unavailable` | 503 | any other failure (the shell logs it) |
109
+ */
110
+ export type ChannelErrorCode =
111
+ | 'invalid_request'
112
+ | 'unauthorized'
113
+ | 'scope_mismatch'
114
+ | 'identity_mismatch'
115
+ | 'identity_required'
116
+ | 'binding_inactive'
117
+ | 'channel_grant_not_tool_authority'
118
+ | 'session_not_found'
119
+ | 'request_conflict'
120
+ | 'session_conflict'
121
+ | 'room_not_encrypted'
122
+ | 'response_expired'
123
+ | 'request_too_large'
124
+ | 'room_not_ready'
125
+ | 'delegation_required'
126
+ | 'rate_limited'
127
+ | 'not_configured'
128
+ | 'binding_check_unavailable'
129
+ | 'unavailable';
130
+
69
131
  export type ChannelTurnOutcome =
70
132
  | { ok: true; result: ChannelTurnResponse }
71
133
  | {
72
134
  ok: false;
73
- status: 400 | 401 | 403 | 404 | 409 | 410 | 413 | 429 | 503;
135
+ status: ChannelErrorStatus;
136
+ code: ChannelErrorCode;
74
137
  message: string;
75
138
  };
76
139
 
77
140
  export class ChannelError extends Error {
78
141
  constructor(
79
- readonly status: 400 | 401 | 403 | 404 | 409 | 410 | 413 | 429 | 503,
142
+ readonly status: ChannelErrorStatus,
80
143
  message: string,
144
+ readonly code: ChannelErrorCode,
81
145
  ) {
82
146
  super(message);
147
+ this.name = 'ChannelError';
83
148
  }
84
149
  }
85
150
 
151
+ /**
152
+ * The least lifetime a delegation must have left for a channel turn to be
153
+ * admitted (seconds): a turn, its tool calls and its owner-copy flush must
154
+ * not outlive the delegation they run under.
155
+ */
156
+ export const CHANNEL_DELEGATION_MIN_REMAINING_SECONDS = 900;
157
+
158
+ /** The user's delegation to this oracle as the user object holds it. */
159
+ export interface ChannelDelegation {
160
+ raw?: string;
161
+ /** Unix seconds: the delegation's effective expiry; absent = unknown. */
162
+ expiration?: number;
163
+ }
164
+
165
+ export function delegationRequiredError(): ChannelError {
166
+ return new ChannelError(
167
+ 428,
168
+ 'Companion delegation required: the user must authorize this oracle again',
169
+ 'delegation_required',
170
+ );
171
+ }
172
+
86
173
  /**
87
174
  * A channel turn runs under the user's stored delegation to this oracle,
88
- * like a Matrix turn. Without one there is no tool authority to run under;
89
- * the turn is refused with an explicit error instead of running degraded.
175
+ * like a Matrix turn. Without one, with an unknown expiry, or with less than
176
+ * `CHANNEL_DELEGATION_MIN_REMAINING_SECONDS` left, there is no tool
177
+ * authority to run under: the turn is refused with an explicit 428
178
+ * `delegation_required` instead of running degraded (memory, files and the
179
+ * sandbox would fail silently).
90
180
  */
91
- export function requireChannelDelegation(present: boolean): void {
92
- if (!present)
93
- throw new ChannelError(
94
- 409,
95
- 'Companion delegation required: the user must authorize this oracle again',
96
- );
181
+ export function requireChannelDelegation(
182
+ delegation: ChannelDelegation | undefined,
183
+ nowSeconds: number,
184
+ ): void {
185
+ if (
186
+ !delegation?.raw ||
187
+ typeof delegation.expiration !== 'number' ||
188
+ !Number.isFinite(delegation.expiration) ||
189
+ delegation.expiration <=
190
+ nowSeconds + CHANNEL_DELEGATION_MIN_REMAINING_SECONDS
191
+ )
192
+ throw delegationRequiredError();
97
193
  }
98
194
 
99
195
  export async function channelRequestHash(raw: string): Promise<string> {
@@ -108,7 +204,12 @@ export async function channelRequestHash(raw: string): Promise<string> {
108
204
 
109
205
  export async function readChannelBody(request: Request): Promise<string> {
110
206
  const reader = request.body?.getReader();
111
- if (!reader) throw new ChannelError(400, 'A channel turn is required');
207
+ if (!reader)
208
+ throw new ChannelError(
209
+ 400,
210
+ 'A channel turn is required',
211
+ 'invalid_request',
212
+ );
112
213
  const chunks: Uint8Array[] = [];
113
214
  let size = 0;
114
215
  while (true) {
@@ -117,7 +218,11 @@ export async function readChannelBody(request: Request): Promise<string> {
117
218
  size += chunk.value.byteLength;
118
219
  if (size > 64_000) {
119
220
  await reader.cancel();
120
- throw new ChannelError(413, 'Channel request is too large');
221
+ throw new ChannelError(
222
+ 413,
223
+ 'Channel request is too large',
224
+ 'request_too_large',
225
+ );
121
226
  }
122
227
  chunks.push(chunk.value);
123
228
  }
@@ -132,6 +237,10 @@ export async function readChannelBody(request: Request): Promise<string> {
132
237
  bytes,
133
238
  );
134
239
  } catch {
135
- throw new ChannelError(400, 'Channel request must be UTF-8');
240
+ throw new ChannelError(
241
+ 400,
242
+ 'Channel request must be UTF-8',
243
+ 'invalid_request',
244
+ );
136
245
  }
137
246
  }
@@ -23,7 +23,10 @@ interface ReceiptRow extends Record<string, string | number | null> {
23
23
  export interface ChannelTurnsHost {
24
24
  createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
25
25
  assertSession(identity: TurnIdentity, sessionId: string): Promise<void>;
26
- /** Throws a `ChannelError` when the user has no stored delegation for this oracle. */
26
+ /**
27
+ * Throws a `ChannelError` (428 `delegation_required`) when the user has no
28
+ * usable delegation for this oracle: none, or one too close to expiry.
29
+ */
27
30
  requireDelegation(identity: TurnIdentity): Promise<void>;
28
31
  getRun(runId: string): Promise<RunRecord | undefined>;
29
32
  /** The Reply Plan (JSON) a finished run was built into, if any. */
@@ -167,6 +170,7 @@ export class ChannelTurns {
167
170
  throw new ChannelError(
168
171
  403,
169
172
  'Channel identity does not match this request',
173
+ 'identity_mismatch',
170
174
  );
171
175
  const runId = `channel_${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`;
172
176
  // Checked before a receipt is written: the receipt of a pruned run is
@@ -175,6 +179,7 @@ export class ChannelTurns {
175
179
  throw new ChannelError(
176
180
  410,
177
181
  'Channel response has expired; this request cannot execute again',
182
+ 'response_expired',
178
183
  );
179
184
  const admitted = await this.db.run(
180
185
  `INSERT OR IGNORE INTO channel_requests
@@ -191,6 +196,7 @@ export class ChannelTurns {
191
196
  throw new ChannelError(
192
197
  409,
193
198
  'This request ID already belongs to another message',
199
+ 'request_conflict',
194
200
  );
195
201
  let sessionId = receipt.session_id;
196
202
  // The session is checked when this request is bound to it, and again
@@ -203,7 +209,11 @@ export class ChannelTurns {
203
209
  [input.bindingId],
204
210
  );
205
211
  if (binding && input.sessionId && binding.session_id !== input.sessionId)
206
- throw new ChannelError(409, 'This channel already has another session');
212
+ throw new ChannelError(
213
+ 409,
214
+ 'This channel already has another session',
215
+ 'session_conflict',
216
+ );
207
217
  sessionId =
208
218
  binding?.session_id ??
209
219
  input.sessionId ??
@@ -4,6 +4,7 @@ import {
4
4
  ChannelError,
5
5
  channelRequestHash,
6
6
  channelOrigin,
7
+ delegationRequiredError,
7
8
  requireChannelDelegation,
8
9
  type ChannelTurnInput,
9
10
  type ChannelTurnOutcome,
@@ -311,7 +312,11 @@ import type {
311
312
  FeedbackSettlement,
312
313
  FeedbackTarget,
313
314
  } from '../feedback/contract';
314
- import { resolveTurnDelegation, WorkersUcanService } from './ucan-service';
315
+ import {
316
+ readDelegation,
317
+ resolveTurnDelegation,
318
+ WorkersUcanService,
319
+ } from './ucan-service';
315
320
  import type { UcanDelegation } from '../plugin-api/types';
316
321
 
317
322
  /** What one attempt's agent build reads from the request body. */
@@ -540,6 +545,17 @@ interface StoredDelegation {
540
545
  at: number;
541
546
  }
542
547
 
548
+ /** The user's delegation as the object holds it in memory. */
549
+ interface CachedDelegation {
550
+ raw: string;
551
+ /**
552
+ * Unix seconds: the delegation's effective expiry. Taken from where the
553
+ * delegation arrived (the shell's validation, the deposit record) or read
554
+ * off the token itself; absent only when the token cannot be parsed.
555
+ */
556
+ expiration?: number;
557
+ }
558
+
543
559
  interface FlushResult {
544
560
  uploaded: boolean;
545
561
  /** Raw file size (skips) or bytes sent upstream (uploads). */
@@ -761,7 +777,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
761
777
  * events of one request (a soft then a hard prune) never race.
762
778
  */
763
779
  private contextStatsWrites: Promise<void> = Promise.resolve();
764
- private readonly delegations = new Map<string, { raw: string }>();
780
+ private readonly delegations = new Map<string, CachedDelegation>();
765
781
  /** Epoch-ms until which a room-state delegation lookup is not retried. */
766
782
  private delegationMissUntil = 0;
767
783
  private dirty = false;
@@ -977,9 +993,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
977
993
  this.delegations.get(identity.userDid)?.raw ??
978
994
  (await this.ctx.storage.get<StoredDelegation>(META_DELEGATION))?.raw;
979
995
  delegationReplaced = known !== identity.ucanDelegation;
980
- this.delegations.set(identity.userDid, {
981
- raw: identity.ucanDelegation,
982
- });
996
+ const cached = this.delegations.get(identity.userDid);
997
+ if (
998
+ cached?.raw !== identity.ucanDelegation ||
999
+ typeof cached.expiration !== 'number'
1000
+ )
1001
+ this.delegations.set(
1002
+ identity.userDid,
1003
+ await this.delegationRecord(
1004
+ identity.ucanDelegation,
1005
+ identity.ucanDelegationExpiration,
1006
+ ),
1007
+ );
983
1008
  // Remember a new one for turns that arrive without the header.
984
1009
  if (delegationReplaced)
985
1010
  await this.ctx.storage.put(META_DELEGATION, {
@@ -1125,7 +1150,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1125
1150
  const cached =
1126
1151
  await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
1127
1152
  if (live(cached)) {
1128
- this.delegations.set(userDid, { raw: cached.raw });
1153
+ this.delegations.set(
1154
+ userDid,
1155
+ await this.delegationRecord(cached.raw, cached.expiration),
1156
+ );
1129
1157
  return;
1130
1158
  }
1131
1159
  if (Date.now() < this.delegationMissUntil) return;
@@ -1156,7 +1184,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1156
1184
  }
1157
1185
  : null;
1158
1186
  if (live(deposited)) {
1159
- this.delegations.set(userDid, { raw: deposited.raw });
1187
+ this.delegations.set(
1188
+ userDid,
1189
+ await this.delegationRecord(deposited.raw, deposited.expiration),
1190
+ );
1160
1191
  await this.ctx.storage.put(META_DELEGATION, deposited);
1161
1192
  console.log(
1162
1193
  `[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
@@ -1171,6 +1202,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1171
1202
  this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
1172
1203
  }
1173
1204
 
1205
+ /**
1206
+ * The in-memory record of a delegation: the expiry where it arrived
1207
+ * stated one (the shell validated the token and read it off the chain),
1208
+ * else the token's own effective expiry along its proof chain. A token
1209
+ * that cannot be parsed keeps no expiry; channel turns refuse it.
1210
+ */
1211
+ private async delegationRecord(
1212
+ raw: string,
1213
+ expiration: number | undefined,
1214
+ ): Promise<CachedDelegation> {
1215
+ if (typeof expiration === 'number' && Number.isFinite(expiration))
1216
+ return { raw, expiration };
1217
+ try {
1218
+ const grant = await readDelegation(raw);
1219
+ return typeof grant.expiration === 'number'
1220
+ ? { raw, expiration: grant.expiration }
1221
+ : { raw };
1222
+ } catch (err) {
1223
+ console.warn(
1224
+ `[user-do] could not read the expiry of a delegation: ${err instanceof Error ? err.message : String(err)}`,
1225
+ );
1226
+ return { raw };
1227
+ }
1228
+ }
1229
+
1174
1230
  private async boot(userDid: string): Promise<void> {
1175
1231
  const stored = await this.ctx.storage.get<string>(META_USER_DID);
1176
1232
  if (stored && stored !== userDid)
@@ -1616,7 +1672,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1616
1672
  this.channelTurns = new ChannelTurns(liveDb, {
1617
1673
  createSession: async (identity, markerTxnId) => {
1618
1674
  const room = await this.gateway.resolveUserRoom(identity.userDid);
1619
- if (!room) throw new ChannelError(409, 'Companion room is not ready');
1675
+ if (!room)
1676
+ throw new ChannelError(
1677
+ 428,
1678
+ 'Companion room is not ready',
1679
+ 'room_not_ready',
1680
+ );
1620
1681
  return (
1621
1682
  await this.createSession(identity, {
1622
1683
  roomId: room.roomId,
@@ -1633,7 +1694,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1633
1694
  !room ||
1634
1695
  session.roomId !== room.roomId
1635
1696
  )
1636
- throw new ChannelError(404, 'Companion session not found');
1697
+ throw new ChannelError(
1698
+ 404,
1699
+ 'Companion session not found',
1700
+ 'session_not_found',
1701
+ );
1637
1702
  const encryption = await this.gateway.getRoomStateEvent(
1638
1703
  room.roomId,
1639
1704
  'm.room.encryption',
@@ -1645,11 +1710,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1645
1710
  !('algorithm' in parsed) ||
1646
1711
  parsed.algorithm !== 'm.megolm.v1.aes-sha2'
1647
1712
  )
1648
- throw new ChannelError(409, 'Companion room must be encrypted');
1713
+ throw new ChannelError(
1714
+ 409,
1715
+ 'Companion room must be encrypted',
1716
+ 'room_not_encrypted',
1717
+ );
1649
1718
  },
1719
+ // Checked again as the run starts: admission may have waited.
1650
1720
  requireDelegation: async (identity) =>
1651
1721
  requireChannelDelegation(
1652
- !!this.delegations.get(identity.userDid)?.raw,
1722
+ this.delegations.get(identity.userDid),
1723
+ Math.floor(Date.now() / 1000),
1653
1724
  ),
1654
1725
  getRun: (runId) => this.runStore!.get(runId),
1655
1726
  getPlan: (runId) => this.runStore!.getPlan(runId),
@@ -2558,16 +2629,52 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2558
2629
  throw new ChannelError(
2559
2630
  403,
2560
2631
  'Channel grants cannot become tool authority',
2632
+ 'channel_grant_not_tool_authority',
2561
2633
  );
2562
2634
  // The shell (the only caller) checked the binding just before.
2563
- await this.ready(identity);
2635
+ // The delegation is checked BEFORE the object boots: a cold boot
2636
+ // loads the owner copy under it, and a user without a usable one
2637
+ // must get `delegation_required`, not a boot failure. Hydration
2638
+ // reads the cached copy, then the deposited room state (throttled
2639
+ // after a miss); an in-memory one that no longer passes is
2640
+ // re-read too, in case a newer deposit did not reach this object.
2641
+ const nowSeconds = Math.floor(Date.now() / 1000);
2642
+ try {
2643
+ requireChannelDelegation(
2644
+ this.delegations.get(identity.userDid),
2645
+ nowSeconds,
2646
+ );
2647
+ } catch {
2648
+ await this.hydrateDelegation(identity.userDid);
2649
+ requireChannelDelegation(
2650
+ this.delegations.get(identity.userDid),
2651
+ nowSeconds,
2652
+ );
2653
+ }
2654
+ try {
2655
+ await this.ready(identity);
2656
+ } catch (error) {
2657
+ // A delegation that lacks the file-storage grant over
2658
+ // `/.oracles` cannot load the owner copy: the user must
2659
+ // authorize again, exactly as without a delegation. (Boot throws
2660
+ // the failure as its RPC envelope, so it is parsed, not matched
2661
+ // by class.)
2662
+ if (parseOwnerCopyFailure(error)?.code === 'NO_VFS_DELEGATION')
2663
+ throw delegationRequiredError();
2664
+ throw error;
2665
+ }
2564
2666
  return {
2565
2667
  ok: true,
2566
2668
  result: await this.channelTurns!.submit(identity, input, requestHash),
2567
2669
  };
2568
2670
  } catch (error) {
2569
2671
  if (error instanceof ChannelError)
2570
- return { ok: false, status: error.status, message: error.message };
2672
+ return {
2673
+ ok: false,
2674
+ status: error.status,
2675
+ code: error.code,
2676
+ message: error.message,
2677
+ };
2571
2678
  throw error;
2572
2679
  }
2573
2680
  }
@@ -2891,7 +2998,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2891
2998
  raw: string,
2892
2999
  expiration?: number,
2893
3000
  ): Promise<void> {
2894
- this.delegations.set(userDid, { raw });
3001
+ this.delegations.set(
3002
+ userDid,
3003
+ await this.delegationRecord(raw, expiration),
3004
+ );
2895
3005
  this.delegationMissUntil = 0;
2896
3006
  await this.ctx.storage.put(META_DELEGATION, {
2897
3007
  raw,
@@ -3001,12 +3111,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3001
3111
  await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
3002
3112
  const inMemory = this.delegations.get(userDid);
3003
3113
  if (inMemory) {
3114
+ const expiration = inMemory.expiration ?? stored?.expiration;
3004
3115
  return {
3005
3116
  present: true,
3006
3117
  source: 'memory',
3007
- ...(typeof stored?.expiration === 'number'
3008
- ? { expiration: stored.expiration }
3009
- : {}),
3118
+ ...(typeof expiration === 'number' ? { expiration } : {}),
3010
3119
  };
3011
3120
  }
3012
3121
  if (stored?.raw) {
@@ -4076,7 +4185,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4076
4185
  if (live.attemptSource !== 'begin')
4077
4186
  await assertChannelAttemptAllowed(req.identity, this.env);
4078
4187
  requireChannelDelegation(
4079
- !!this.delegations.get(req.identity.userDid)?.raw,
4188
+ this.delegations.get(req.identity.userDid),
4189
+ Math.floor(Date.now() / 1000),
4080
4190
  );
4081
4191
  }
4082
4192
  // An attempt that starts from `admitting` never reached the agent (the
package/src/shell/app.ts CHANGED
@@ -139,6 +139,20 @@ function errorText(err: unknown): string {
139
139
  return err instanceof Error ? err.message : String(err);
140
140
  }
141
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
+
142
156
  /** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
143
157
  function isAbortRejection(err: unknown): boolean {
144
158
  return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
@@ -405,16 +419,21 @@ export function createShell(
405
419
  app.post('/channels/turn', async (c) => {
406
420
  try {
407
421
  if (!c.env.CHANNEL_SERVICE_DID)
408
- return c.json({ message: 'Channels are not configured' }, 503);
422
+ throw new ChannelError(
423
+ 503,
424
+ 'Channels are not configured',
425
+ 'not_configured',
426
+ );
409
427
  const raw = await readChannelBody(c.req.raw);
410
428
  let json: unknown;
411
429
  try {
412
430
  json = JSON.parse(raw);
413
431
  } catch {
414
- throw new ChannelError(400, 'Invalid JSON request');
432
+ throw new ChannelError(400, 'Invalid JSON request', 'invalid_request');
415
433
  }
416
434
  const parsed = ChannelTurnBody.safeParse(json);
417
- if (!parsed.success) throw new ChannelError(400, 'Invalid channel turn');
435
+ if (!parsed.success)
436
+ throw new ChannelError(400, 'Invalid channel turn', 'invalid_request');
418
437
  const identity = await authenticateChannel(
419
438
  c.req.raw.headers,
420
439
  raw,
@@ -431,7 +450,7 @@ export function createShell(
431
450
  })
432
451
  ).success
433
452
  )
434
- throw new ChannelError(429, 'Too many requests');
453
+ throw new ChannelError(429, 'Too many requests', 'rate_limited');
435
454
  await assertActiveChannelBinding(identity, c.env);
436
455
  const outcome = await userStub(c.env, identity.userDid).channelTurn(
437
456
  identity,
@@ -439,7 +458,10 @@ export function createShell(
439
458
  await channelRequestHash(raw),
440
459
  );
441
460
  if (!outcome.ok)
442
- return c.json({ message: outcome.message }, outcome.status);
461
+ return c.json(
462
+ { code: outcome.code, message: outcome.message },
463
+ outcome.status,
464
+ );
443
465
  const result = outcome.result;
444
466
  return c.json(
445
467
  result,
@@ -447,9 +469,20 @@ export function createShell(
447
469
  );
448
470
  } catch (error) {
449
471
  if (error instanceof ChannelError)
450
- return c.json({ message: error.message }, error.status);
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
+ );
451
481
  return c.json(
452
- { message: 'Channel turn is temporarily unavailable' },
482
+ {
483
+ code: 'unavailable',
484
+ message: 'Channel turn is temporarily unavailable',
485
+ },
453
486
  503,
454
487
  );
455
488
  }
@@ -1084,7 +1117,7 @@ export function createShell(
1084
1117
  // Thrown on purpose with a status and a message meant for the client.
1085
1118
  if (err instanceof HTTPException) return err.getResponse();
1086
1119
  if (err instanceof ChannelError)
1087
- return c.json({ message: err.message }, err.status);
1120
+ return c.json({ code: err.code, message: err.message }, err.status);
1088
1121
  const requestId = errorRequestId(c.req.header('x-request-id'));
1089
1122
  console.error(
1090
1123
  `[shell] ${c.req.method} ${loggedRoute(c)} failed (request ${requestId}):`,