@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 +3 -3
- package/src/channels/auth.ts +65 -0
- package/src/channels/contract.ts +32 -2
- package/src/channels/failures.ts +64 -0
- package/src/channels/turns.ts +660 -105
- package/src/core/domain-context/resolver.ts +13 -1
- package/src/core/domain-context/transport.ts +52 -10
- package/src/core/index.ts +36 -3
- package/src/do/contracts.ts +10 -0
- package/src/do/room-mirror.ts +7 -2
- package/src/do/run-store-test-do.ts +2 -2
- package/src/do/run-store.ts +41 -6
- package/src/do/user-oracle-do.ts +191 -78
- package/src/shell/app.ts +16 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "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/
|
|
61
|
+
"@ixo/ucan": "2.2.1",
|
|
62
62
|
"@ixo/common": "1.6.0",
|
|
63
|
-
"@ixo/
|
|
63
|
+
"@ixo/ixo-transaction": "0.1.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
package/src/channels/auth.ts
CHANGED
|
@@ -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
|
package/src/channels/contract.ts
CHANGED
|
@@ -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 |
|
|
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
|
+
}
|