@ixo/oracle-runtime-workers 0.17.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.17.0",
3
+ "version": "0.18.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": {
@@ -58,9 +58,9 @@
58
58
  "wa-sqlite": "1.0.0",
59
59
  "yjs": "13.6.32",
60
60
  "zod": "^4.4.3",
61
- "@ixo/ixo-transaction": "0.1.0",
61
+ "@ixo/ucan": "2.2.1",
62
62
  "@ixo/common": "1.6.0",
63
- "@ixo/ucan": "2.2.1"
63
+ "@ixo/ixo-transaction": "0.1.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -289,6 +289,71 @@ export async function assertActiveChannelBinding(
289
289
  );
290
290
  }
291
291
 
292
+ /**
293
+ * How long an isolate trusts an Auth Hub "active" verdict for one binding
294
+ * revision and user. A revoked binding is therefore noticed by new
295
+ * admissions at most this late: inside the lifetime of the channel grant
296
+ * that authorized the request (at most 300 s, `authenticateChannel`), and
297
+ * attempts that start later (dequeued, recovered) check again uncached.
298
+ */
299
+ export const CHANNEL_BINDING_CACHE_TTL_MS = 60_000;
300
+ /** Verdicts an isolate keeps at most; the oldest makes room. */
301
+ export const CHANNEL_BINDING_CACHE_MAX_ENTRIES = 1_000;
302
+
303
+ /** Positive verdicts only (key → epoch ms it expires); a refusal is never kept. */
304
+ const activeBindings = new Map<string, number>();
305
+
306
+ /** Forget every cached verdict (tests). */
307
+ export function clearChannelBindingCache(): void {
308
+ activeBindings.clear();
309
+ }
310
+
311
+ /**
312
+ * `assertActiveChannelBinding` for an admission through the shell, with an
313
+ * "active" verdict cached per isolate for `CHANNEL_BINDING_CACHE_TTL_MS`,
314
+ * keyed by the oracle, provider, binding, binding revision and user. An
315
+ * inactive binding (403) or an unavailable Auth Hub (503) is never cached.
316
+ * Returns whether the Auth Hub was asked.
317
+ */
318
+ export async function assertActiveChannelBindingCached(
319
+ identity: TurnIdentity,
320
+ env: Parameters<typeof assertActiveChannelBinding>[1],
321
+ now: number = Date.now(),
322
+ ): Promise<'cached' | 'checked'> {
323
+ const channel = identity.channel;
324
+ const key = channel
325
+ ? JSON.stringify([
326
+ env.ORACLE_DID,
327
+ channel.provider,
328
+ channel.bindingId,
329
+ channel.bindingRevision,
330
+ identity.userDid,
331
+ ])
332
+ : null;
333
+ if (key) {
334
+ const until = activeBindings.get(key);
335
+ if (until !== undefined) {
336
+ if (until > now) return 'cached';
337
+ activeBindings.delete(key);
338
+ }
339
+ }
340
+ await assertActiveChannelBinding(identity, env);
341
+ if (!key) return 'checked';
342
+ if (activeBindings.size >= CHANNEL_BINDING_CACHE_MAX_ENTRIES) {
343
+ for (const [cached, until] of activeBindings)
344
+ if (until <= now) activeBindings.delete(cached);
345
+ // Still full: drop the oldest entry (Map keeps insertion order).
346
+ const oldest = activeBindings.keys().next();
347
+ if (
348
+ activeBindings.size >= CHANNEL_BINDING_CACHE_MAX_ENTRIES &&
349
+ !oldest.done
350
+ )
351
+ activeBindings.delete(oldest.value);
352
+ }
353
+ activeBindings.set(key, now + CHANNEL_BINDING_CACHE_TTL_MS);
354
+ return 'checked';
355
+ }
356
+
292
357
  /**
293
358
  * The binding check before a channel attempt executes (a queued run being
294
359
  * dequeued, a recovery attempt). An inactive binding (403) ends the run. An
@@ -93,11 +93,13 @@ export type ChannelErrorStatus =
93
93
  * | `identity_mismatch` | 403 | the channel identity does not match the request |
94
94
  * | `identity_required` | 403 | the binding check got no channel identity |
95
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 |
96
+ * | `channel_grant_not_tool_authority`| 403 | a channel request carried a delegation header | * | `storage_unauthorized` | 403 | the user's file storage rejected the oracle (final) |
97
97
  * | `session_not_found` | 404 | the session is not the user's Companion session |
98
98
  * | `request_conflict` | 409 | the request id already belongs to another message |
99
- * | `session_conflict` | 409 | the channel is already bound to another session |
99
+ * | `session_conflict` | 409 | another session is bound, or the named one is gone: |
100
+ * | | | resend the same request without `sessionId` |
100
101
  * | `room_not_encrypted` | 409 | the Companion room is not end-to-end encrypted |
102
+ * | `room_unavailable` | 409 | the oracle cannot use the Companion room (final) |
101
103
  * | `response_expired` | 410 | the run was pruned; the request cannot run again |
102
104
  * | `request_too_large` | 413 | the body is over 64 kB |
103
105
  * | `room_not_ready` | 428 | the user has no Companion room yet |
@@ -115,10 +117,12 @@ export type ChannelErrorCode =
115
117
  | 'identity_required'
116
118
  | 'binding_inactive'
117
119
  | 'channel_grant_not_tool_authority'
120
+ | 'storage_unauthorized'
118
121
  | 'session_not_found'
119
122
  | 'request_conflict'
120
123
  | 'session_conflict'
121
124
  | 'room_not_encrypted'
125
+ | 'room_unavailable'
122
126
  | 'response_expired'
123
127
  | 'request_too_large'
124
128
  | 'room_not_ready'
@@ -148,6 +152,32 @@ export class ChannelError extends Error {
148
152
  }
149
153
  }
150
154
 
155
+ /**
156
+ * The session a channel request would run in does not exist in the user's
157
+ * object (deleted in Qi.Space, or never created there). It answers like
158
+ * `session_not_found` wherever it escapes, but admission handles it first:
159
+ * the binding forgets the session and the request is answered with
160
+ * `session_conflict` (or, when the request named no session, opens a new
161
+ * one at once).
162
+ */
163
+ export class StaleSessionError extends ChannelError {
164
+ constructor(message = 'Companion session not found') {
165
+ super(404, message, 'session_not_found');
166
+ this.name = 'StaleSessionError';
167
+ }
168
+ }
169
+
170
+ /**
171
+ * The run of one channel request: fixed by the binding and the request id,
172
+ * so every retry, poll and object restart lands on the same run.
173
+ */
174
+ export async function channelRunId(
175
+ bindingId: string,
176
+ requestId: string,
177
+ ): Promise<string> {
178
+ return `channel_${await channelRequestHash(JSON.stringify([bindingId, requestId]))}`;
179
+ }
180
+
151
181
  /**
152
182
  * The least lifetime a delegation must have left for a channel turn to be
153
183
  * admitted (seconds): a turn, its tool calls and its owner-copy flush must
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Failures on the channel path that retrying cannot fix. Everything else a
3
+ * channel turn throws stays a `503 unavailable` the gateway retries with
4
+ * backoff; these two are answered as final refusals instead, so the gateway
5
+ * sends its failure reply at once rather than after hours of retries.
6
+ *
7
+ * Errors cross Durable Object RPC as a plain `Error` carrying only its
8
+ * message (the class, `name` and custom fields are gone on the caller's
9
+ * side), so both are recognised by what survives: the owner-copy failure's
10
+ * JSON envelope (`parseOwnerCopyFailure`) and matrix-js-sdk's
11
+ * `MatrixError: [<status>] …` message wording.
12
+ */
13
+ import { parseOwnerCopyFailure } from '../owner-store/owner-copy-errors';
14
+ import { ChannelError } from './contract';
15
+
16
+ const errorMessage = (error: unknown): string =>
17
+ error instanceof Error ? error.message : String(error);
18
+
19
+ /**
20
+ * The homeserver refused the oracle in the user's room: the bot was kicked
21
+ * or left (`M_FORBIDDEN`, HTTP 403), or the SDK knows the room as one the
22
+ * bot has not joined.
23
+ */
24
+ export function isRoomForbidden(error: unknown): boolean {
25
+ if (
26
+ error instanceof Error &&
27
+ (error.name === 'M_FORBIDDEN' ||
28
+ ('errcode' in error && error.errcode === 'M_FORBIDDEN'))
29
+ )
30
+ return true;
31
+ const message = errorMessage(error);
32
+ return (
33
+ /\bM_FORBIDDEN\b/.test(message) ||
34
+ /MatrixError: \[403\]/.test(message) ||
35
+ /is not a joined room known to the bot/.test(message) ||
36
+ /\bnot (in|a member of) (the |this )?room\b/i.test(message)
37
+ );
38
+ }
39
+
40
+ /**
41
+ * The final refusal for a failure retrying cannot fix, or null when the
42
+ * failure stays a transient `503 unavailable`:
43
+ *
44
+ * - the Companion room refused the oracle → `409 room_unavailable`;
45
+ * - the user's file storage rejected the oracle's credentials while the
46
+ * owner copy loaded (`VFS_AUTH_FAILED`; `NO_VFS_DELEGATION` is a
47
+ * `428 delegation_required` and is handled before this) →
48
+ * `403 storage_unauthorized`.
49
+ */
50
+ export function classifyChannelFailure(error: unknown): ChannelError | null {
51
+ if (parseOwnerCopyFailure(error)?.code === 'VFS_AUTH_FAILED')
52
+ return new ChannelError(
53
+ 403,
54
+ "The user's file storage rejected the oracle; the Companion cannot load the user's data",
55
+ 'storage_unauthorized',
56
+ );
57
+ if (isRoomForbidden(error))
58
+ return new ChannelError(
59
+ 409,
60
+ 'The oracle cannot use the Companion room (it is not a member or was refused)',
61
+ 'room_unavailable',
62
+ );
63
+ return null;
64
+ }