@ixo/oracle-runtime-workers 0.17.1 → 0.19.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 +95 -8
- package/src/channels/contract.ts +53 -10
- package/src/channels/failures.ts +64 -0
- package/src/channels/turns.ts +661 -106
- package/src/core/capability-router.ts +1 -0
- package/src/core/index.ts +36 -3
- package/src/delivery/profile.ts +4 -1
- package/src/delivery/schema.ts +1 -1
- package/src/do/contracts.ts +26 -5
- package/src/do/room-mirror.ts +8 -3
- 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 -80
- package/src/shell/app.ts +18 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"yjs": "13.6.32",
|
|
60
60
|
"zod": "^4.4.3",
|
|
61
61
|
"@ixo/ixo-transaction": "0.1.0",
|
|
62
|
-
"@ixo/
|
|
63
|
-
"@ixo/
|
|
62
|
+
"@ixo/ucan": "2.2.1",
|
|
63
|
+
"@ixo/common": "1.7.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
package/src/channels/auth.ts
CHANGED
|
@@ -41,7 +41,8 @@ export const ChannelInvoke = defineCapability({
|
|
|
41
41
|
|
|
42
42
|
export interface ChannelAuthConfig {
|
|
43
43
|
oracleDid: string;
|
|
44
|
-
|
|
44
|
+
/** Every channel service DID allowed to invoke this deployment. */
|
|
45
|
+
channelDids: readonly string[];
|
|
45
46
|
didResolver: DIDKeyResolver;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -85,12 +86,16 @@ export async function authenticateChannel(
|
|
|
85
86
|
);
|
|
86
87
|
const chain = result.proofChain;
|
|
87
88
|
const userDid = chain?.[0];
|
|
89
|
+
// The invoker is the one listed channel service that signed this
|
|
90
|
+
// invocation; the user's grant must be addressed to that same service.
|
|
91
|
+
const channelDid = result.invoker;
|
|
88
92
|
if (
|
|
89
93
|
!result.ok ||
|
|
90
94
|
!userDid?.startsWith('did:ixo:') ||
|
|
91
95
|
chain?.length !== 2 ||
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
!channelDid ||
|
|
97
|
+
!config.channelDids.includes(channelDid) ||
|
|
98
|
+
chain[1] !== channelDid
|
|
94
99
|
)
|
|
95
100
|
throw new ChannelError(
|
|
96
101
|
401,
|
|
@@ -106,7 +111,7 @@ export async function authenticateChannel(
|
|
|
106
111
|
!('issuer' in proof) ||
|
|
107
112
|
proof.proofs.length !== 0 ||
|
|
108
113
|
proof.issuer.did() !== userDid ||
|
|
109
|
-
proof.audience.did() !==
|
|
114
|
+
proof.audience.did() !== channelDid ||
|
|
110
115
|
proof.capabilities.length !== 1 ||
|
|
111
116
|
proof.capabilities[0]?.can !== 'ixo:channel/invoke' ||
|
|
112
117
|
proof.capabilities[0]?.with !== `ixo:channel:${input.bindingId}` ||
|
|
@@ -159,7 +164,7 @@ export async function authenticateChannel(
|
|
|
159
164
|
return {
|
|
160
165
|
userDid,
|
|
161
166
|
channel: {
|
|
162
|
-
callerDid:
|
|
167
|
+
callerDid: channelDid,
|
|
163
168
|
provider: input.provider,
|
|
164
169
|
bindingId: input.bindingId,
|
|
165
170
|
bindingRevision: input.bindingRevision,
|
|
@@ -176,13 +181,30 @@ const channelWebResolver = createWebDIDResolver({
|
|
|
176
181
|
cacheTtlMs: IXO_DID_RESOLUTION_CACHE_TTL_MS,
|
|
177
182
|
});
|
|
178
183
|
|
|
184
|
+
/**
|
|
185
|
+
* `CHANNEL_SERVICE_DID` is one DID or a comma-separated list (one channel
|
|
186
|
+
* service per messaging provider, or a rotation overlap): entries are
|
|
187
|
+
* trimmed, empty ones dropped and duplicates removed, order kept.
|
|
188
|
+
*/
|
|
189
|
+
export function parseChannelServiceDids(raw: string | undefined): string[] {
|
|
190
|
+
return [
|
|
191
|
+
...new Set(
|
|
192
|
+
(raw ?? '')
|
|
193
|
+
.split(',')
|
|
194
|
+
.map((did) => did.trim())
|
|
195
|
+
.filter((did) => did !== ''),
|
|
196
|
+
),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
179
200
|
export function channelAuthConfig(
|
|
180
201
|
env: Pick<
|
|
181
202
|
OracleWorkerEnv,
|
|
182
203
|
'CHANNEL_SERVICE_DID' | 'BLOCKSYNC_GRAPHQL_URL' | 'ORACLE_DID'
|
|
183
204
|
>,
|
|
184
205
|
): ChannelAuthConfig {
|
|
185
|
-
|
|
206
|
+
const channelDids = parseChannelServiceDids(env.CHANNEL_SERVICE_DID);
|
|
207
|
+
if (channelDids.length === 0)
|
|
186
208
|
throw new ChannelError(
|
|
187
209
|
503,
|
|
188
210
|
'Channels are not configured',
|
|
@@ -194,10 +216,10 @@ export function channelAuthConfig(
|
|
|
194
216
|
]);
|
|
195
217
|
return {
|
|
196
218
|
oracleDid: env.ORACLE_DID,
|
|
197
|
-
|
|
219
|
+
channelDids,
|
|
198
220
|
didResolver: async (did) => {
|
|
199
221
|
if (
|
|
200
|
-
did
|
|
222
|
+
channelDids.includes(did) ||
|
|
201
223
|
did === env.ORACLE_DID ||
|
|
202
224
|
did.startsWith('did:ixo:')
|
|
203
225
|
)
|
|
@@ -289,6 +311,71 @@ export async function assertActiveChannelBinding(
|
|
|
289
311
|
);
|
|
290
312
|
}
|
|
291
313
|
|
|
314
|
+
/**
|
|
315
|
+
* How long an isolate trusts an Auth Hub "active" verdict for one binding
|
|
316
|
+
* revision and user. A revoked binding is therefore noticed by new
|
|
317
|
+
* admissions at most this late: inside the lifetime of the channel grant
|
|
318
|
+
* that authorized the request (at most 300 s, `authenticateChannel`), and
|
|
319
|
+
* attempts that start later (dequeued, recovered) check again uncached.
|
|
320
|
+
*/
|
|
321
|
+
export const CHANNEL_BINDING_CACHE_TTL_MS = 60_000;
|
|
322
|
+
/** Verdicts an isolate keeps at most; the oldest makes room. */
|
|
323
|
+
export const CHANNEL_BINDING_CACHE_MAX_ENTRIES = 1_000;
|
|
324
|
+
|
|
325
|
+
/** Positive verdicts only (key → epoch ms it expires); a refusal is never kept. */
|
|
326
|
+
const activeBindings = new Map<string, number>();
|
|
327
|
+
|
|
328
|
+
/** Forget every cached verdict (tests). */
|
|
329
|
+
export function clearChannelBindingCache(): void {
|
|
330
|
+
activeBindings.clear();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `assertActiveChannelBinding` for an admission through the shell, with an
|
|
335
|
+
* "active" verdict cached per isolate for `CHANNEL_BINDING_CACHE_TTL_MS`,
|
|
336
|
+
* keyed by the oracle, provider, binding, binding revision and user. An
|
|
337
|
+
* inactive binding (403) or an unavailable Auth Hub (503) is never cached.
|
|
338
|
+
* Returns whether the Auth Hub was asked.
|
|
339
|
+
*/
|
|
340
|
+
export async function assertActiveChannelBindingCached(
|
|
341
|
+
identity: TurnIdentity,
|
|
342
|
+
env: Parameters<typeof assertActiveChannelBinding>[1],
|
|
343
|
+
now: number = Date.now(),
|
|
344
|
+
): Promise<'cached' | 'checked'> {
|
|
345
|
+
const channel = identity.channel;
|
|
346
|
+
const key = channel
|
|
347
|
+
? JSON.stringify([
|
|
348
|
+
env.ORACLE_DID,
|
|
349
|
+
channel.provider,
|
|
350
|
+
channel.bindingId,
|
|
351
|
+
channel.bindingRevision,
|
|
352
|
+
identity.userDid,
|
|
353
|
+
])
|
|
354
|
+
: null;
|
|
355
|
+
if (key) {
|
|
356
|
+
const until = activeBindings.get(key);
|
|
357
|
+
if (until !== undefined) {
|
|
358
|
+
if (until > now) return 'cached';
|
|
359
|
+
activeBindings.delete(key);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
await assertActiveChannelBinding(identity, env);
|
|
363
|
+
if (!key) return 'checked';
|
|
364
|
+
if (activeBindings.size >= CHANNEL_BINDING_CACHE_MAX_ENTRIES) {
|
|
365
|
+
for (const [cached, until] of activeBindings)
|
|
366
|
+
if (until <= now) activeBindings.delete(cached);
|
|
367
|
+
// Still full: drop the oldest entry (Map keeps insertion order).
|
|
368
|
+
const oldest = activeBindings.keys().next();
|
|
369
|
+
if (
|
|
370
|
+
activeBindings.size >= CHANNEL_BINDING_CACHE_MAX_ENTRIES &&
|
|
371
|
+
!oldest.done
|
|
372
|
+
)
|
|
373
|
+
activeBindings.delete(oldest.value);
|
|
374
|
+
}
|
|
375
|
+
activeBindings.set(key, now + CHANNEL_BINDING_CACHE_TTL_MS);
|
|
376
|
+
return 'checked';
|
|
377
|
+
}
|
|
378
|
+
|
|
292
379
|
/**
|
|
293
380
|
* The binding check before a channel attempt executes (a queued run being
|
|
294
381
|
* dequeued, a recovery attempt). An inactive binding (403) ends the run. An
|
package/src/channels/contract.ts
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ReplyPlan } from '../delivery/types';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A channel provider id (`whatsapp`, later others): lower-case, starts with a
|
|
6
|
+
* letter, at most 32 characters. The runtime does not enumerate providers;
|
|
7
|
+
* the gateway and the Auth Hub do. An unknown provider is delivered with the
|
|
8
|
+
* default delivery profile.
|
|
9
|
+
*/
|
|
10
|
+
export const ChannelProviderId = z.string().regex(/^[a-z][a-z0-9_-]{0,31}$/);
|
|
11
|
+
|
|
4
12
|
export const ChannelTurnBody = z.strictObject({
|
|
5
|
-
provider:
|
|
13
|
+
provider: ChannelProviderId,
|
|
6
14
|
bindingId: z.string().regex(/^chb_[A-Za-z0-9_-]{1,120}$/),
|
|
7
15
|
bindingRevision: z.number().int().positive().safe(),
|
|
8
16
|
requestId: z.string().min(1).max(180),
|
|
9
17
|
remoteMessageRef: z.string().regex(/^hmac:[a-f0-9]{64}$/),
|
|
10
18
|
sessionId: z.string().min(1).max(255).optional(),
|
|
11
19
|
message: z.string().min(1).max(16_000),
|
|
12
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Which kind of oracle the gateway believes it addresses. `companion` is
|
|
22
|
+
* the legacy value; the gateway sends `oracle`. Both are accepted.
|
|
23
|
+
*/
|
|
24
|
+
context: z.strictObject({ kind: z.enum(['companion', 'oracle']) }),
|
|
13
25
|
});
|
|
14
26
|
|
|
15
27
|
export type ChannelTurnInput = z.infer<typeof ChannelTurnBody>;
|
|
16
28
|
|
|
17
29
|
export const ChannelOrigin = z.strictObject({
|
|
18
30
|
v: z.literal(1),
|
|
19
|
-
transport:
|
|
31
|
+
transport: ChannelProviderId,
|
|
20
32
|
binding_id: ChannelTurnBody.shape.bindingId,
|
|
21
33
|
remote_ref: ChannelTurnBody.shape.remoteMessageRef,
|
|
22
34
|
});
|
|
@@ -39,7 +51,7 @@ export function channelOrigin(
|
|
|
39
51
|
|
|
40
52
|
export interface ChannelIdentity {
|
|
41
53
|
callerDid: string;
|
|
42
|
-
provider:
|
|
54
|
+
provider: string;
|
|
43
55
|
bindingId: string;
|
|
44
56
|
bindingRevision: number;
|
|
45
57
|
}
|
|
@@ -82,7 +94,7 @@ export type ChannelErrorStatus =
|
|
|
82
94
|
* The machine-readable reason a channel turn was refused. The gateway maps
|
|
83
95
|
* a refusal by `code` first and by HTTP status second. 428 (Precondition
|
|
84
96
|
* Required) means a precondition on the user's side is missing: fix it
|
|
85
|
-
* (authorize the oracle again, finish setting up the
|
|
97
|
+
* (authorize the oracle again, finish setting up the user's oracle room) and
|
|
86
98
|
* retry; it is never a transient outage.
|
|
87
99
|
*
|
|
88
100
|
* | code | status | meaning |
|
|
@@ -94,13 +106,16 @@ export type ChannelErrorStatus =
|
|
|
94
106
|
* | `identity_required` | 403 | the binding check got no channel identity |
|
|
95
107
|
* | `binding_inactive` | 403 | the Auth Hub reports the binding inactive |
|
|
96
108
|
* | `channel_grant_not_tool_authority`| 403 | a channel request carried a delegation header |
|
|
97
|
-
* | `
|
|
109
|
+
* | `storage_unauthorized` | 403 | the user's file storage rejected the oracle (final) |
|
|
110
|
+
* | `session_not_found` | 404 | the session is not the user's oracle session |
|
|
98
111
|
* | `request_conflict` | 409 | the request id already belongs to another message |
|
|
99
|
-
* | `session_conflict` | 409 |
|
|
100
|
-
* |
|
|
112
|
+
* | `session_conflict` | 409 | another session is bound, or the named one is gone: |
|
|
113
|
+
* | | | resend the same request without `sessionId` |
|
|
114
|
+
* | `room_not_encrypted` | 409 | the user's oracle room is not end-to-end encrypted |
|
|
115
|
+
* | `room_unavailable` | 409 | the oracle cannot use the user's oracle room (final) |
|
|
101
116
|
* | `response_expired` | 410 | the run was pruned; the request cannot run again |
|
|
102
117
|
* | `request_too_large` | 413 | the body is over 64 kB |
|
|
103
|
-
* | `room_not_ready` | 428 | the user has no
|
|
118
|
+
* | `room_not_ready` | 428 | the user has no oracle room yet |
|
|
104
119
|
* | `delegation_required` | 428 | no usable delegation to this oracle (see below) |
|
|
105
120
|
* | `rate_limited` | 429 | too many requests for this user |
|
|
106
121
|
* | `not_configured` | 503 | channels or binding validation are not configured |
|
|
@@ -115,10 +130,12 @@ export type ChannelErrorCode =
|
|
|
115
130
|
| 'identity_required'
|
|
116
131
|
| 'binding_inactive'
|
|
117
132
|
| 'channel_grant_not_tool_authority'
|
|
133
|
+
| 'storage_unauthorized'
|
|
118
134
|
| 'session_not_found'
|
|
119
135
|
| 'request_conflict'
|
|
120
136
|
| 'session_conflict'
|
|
121
137
|
| 'room_not_encrypted'
|
|
138
|
+
| 'room_unavailable'
|
|
122
139
|
| 'response_expired'
|
|
123
140
|
| 'request_too_large'
|
|
124
141
|
| 'room_not_ready'
|
|
@@ -148,6 +165,32 @@ export class ChannelError extends Error {
|
|
|
148
165
|
}
|
|
149
166
|
}
|
|
150
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The session a channel request would run in does not exist in the user's
|
|
170
|
+
* object (deleted in Qi.Space, or never created there). It answers like
|
|
171
|
+
* `session_not_found` wherever it escapes, but admission handles it first:
|
|
172
|
+
* the binding forgets the session and the request is answered with
|
|
173
|
+
* `session_conflict` (or, when the request named no session, opens a new
|
|
174
|
+
* one at once).
|
|
175
|
+
*/
|
|
176
|
+
export class StaleSessionError extends ChannelError {
|
|
177
|
+
constructor(message = 'Oracle session not found') {
|
|
178
|
+
super(404, message, 'session_not_found');
|
|
179
|
+
this.name = 'StaleSessionError';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The run of one channel request: fixed by the binding and the request id,
|
|
185
|
+
* so every retry, poll and object restart lands on the same run.
|
|
186
|
+
*/
|
|
187
|
+
export async function channelRunId(
|
|
188
|
+
bindingId: string,
|
|
189
|
+
requestId: string,
|
|
190
|
+
): Promise<string> {
|
|
191
|
+
return `channel_${await channelRequestHash(JSON.stringify([bindingId, requestId]))}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
151
194
|
/**
|
|
152
195
|
* The least lifetime a delegation must have left for a channel turn to be
|
|
153
196
|
* admitted (seconds): a turn, its tool calls and its owner-copy flush must
|
|
@@ -165,7 +208,7 @@ export interface ChannelDelegation {
|
|
|
165
208
|
export function delegationRequiredError(): ChannelError {
|
|
166
209
|
return new ChannelError(
|
|
167
210
|
428,
|
|
168
|
-
'
|
|
211
|
+
'Delegation required: the user must authorize this oracle again',
|
|
169
212
|
'delegation_required',
|
|
170
213
|
);
|
|
171
214
|
}
|
|
@@ -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 user's oracle 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 oracle 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 user's oracle room (it is not a member or was refused)",
|
|
61
|
+
'room_unavailable',
|
|
62
|
+
);
|
|
63
|
+
return null;
|
|
64
|
+
}
|