@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.17.1",
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/common": "1.6.0",
63
- "@ixo/ucan": "2.2.1"
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",
@@ -41,7 +41,8 @@ export const ChannelInvoke = defineCapability({
41
41
 
42
42
  export interface ChannelAuthConfig {
43
43
  oracleDid: string;
44
- channelDid: string;
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
- chain[1] !== config.channelDid ||
93
- result.invoker !== config.channelDid
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() !== config.channelDid ||
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: config.channelDid,
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
- if (!env.CHANNEL_SERVICE_DID)
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
- channelDid: env.CHANNEL_SERVICE_DID,
219
+ channelDids,
198
220
  didResolver: async (did) => {
199
221
  if (
200
- did === env.CHANNEL_SERVICE_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
@@ -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: z.literal('whatsapp'),
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
- context: z.strictObject({ kind: z.literal('companion') }),
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: z.literal('whatsapp'),
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: 'whatsapp';
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 Companion room) and
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
- * | `session_not_found` | 404 | the session is not the user's Companion session |
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 | the channel is already bound to another session |
100
- * | `room_not_encrypted` | 409 | the Companion room is not end-to-end encrypted |
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 Companion room yet |
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
- 'Companion delegation required: the user must authorize this oracle again',
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
+ }