@ixo/oracle-runtime-workers 0.18.0 → 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.18.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": {
@@ -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
62
  "@ixo/ucan": "2.2.1",
62
- "@ixo/common": "1.6.0",
63
- "@ixo/ixo-transaction": "0.1.0"
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
  )
@@ -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 |
@@ -93,16 +105,17 @@ export type ChannelErrorStatus =
93
105
  * | `identity_mismatch` | 403 | the channel identity does not match the request |
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
- * | `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
- * | `session_not_found` | 404 | the session is not the user's Companion session |
108
+ * | `channel_grant_not_tool_authority`| 403 | a channel request carried a delegation header |
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
112
  * | `session_conflict` | 409 | another session is bound, or the named one is gone: |
100
113
  * | | | resend the same request without `sessionId` |
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) |
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) |
103
116
  * | `response_expired` | 410 | the run was pruned; the request cannot run again |
104
117
  * | `request_too_large` | 413 | the body is over 64 kB |
105
- * | `room_not_ready` | 428 | the user has no Companion room yet |
118
+ * | `room_not_ready` | 428 | the user has no oracle room yet |
106
119
  * | `delegation_required` | 428 | no usable delegation to this oracle (see below) |
107
120
  * | `rate_limited` | 429 | too many requests for this user |
108
121
  * | `not_configured` | 503 | channels or binding validation are not configured |
@@ -161,7 +174,7 @@ export class ChannelError extends Error {
161
174
  * one at once).
162
175
  */
163
176
  export class StaleSessionError extends ChannelError {
164
- constructor(message = 'Companion session not found') {
177
+ constructor(message = 'Oracle session not found') {
165
178
  super(404, message, 'session_not_found');
166
179
  this.name = 'StaleSessionError';
167
180
  }
@@ -195,7 +208,7 @@ export interface ChannelDelegation {
195
208
  export function delegationRequiredError(): ChannelError {
196
209
  return new ChannelError(
197
210
  428,
198
- 'Companion delegation required: the user must authorize this oracle again',
211
+ 'Delegation required: the user must authorize this oracle again',
199
212
  'delegation_required',
200
213
  );
201
214
  }
@@ -41,7 +41,7 @@ export function isRoomForbidden(error: unknown): boolean {
41
41
  * The final refusal for a failure retrying cannot fix, or null when the
42
42
  * failure stays a transient `503 unavailable`:
43
43
  *
44
- * - the Companion room refused the oracle → `409 room_unavailable`;
44
+ * - the user's oracle room refused the oracle → `409 room_unavailable`;
45
45
  * - the user's file storage rejected the oracle's credentials while the
46
46
  * owner copy loaded (`VFS_AUTH_FAILED`; `NO_VFS_DELEGATION` is a
47
47
  * `428 delegation_required` and is handled before this) →
@@ -51,13 +51,13 @@ export function classifyChannelFailure(error: unknown): ChannelError | null {
51
51
  if (parseOwnerCopyFailure(error)?.code === 'VFS_AUTH_FAILED')
52
52
  return new ChannelError(
53
53
  403,
54
- "The user's file storage rejected the oracle; the Companion cannot load the user's data",
54
+ "The user's file storage rejected the oracle; the oracle cannot load the user's data",
55
55
  'storage_unauthorized',
56
56
  );
57
57
  if (isRoomForbidden(error))
58
58
  return new ChannelError(
59
59
  409,
60
- 'The oracle cannot use the Companion room (it is not a member or was refused)',
60
+ "The oracle cannot use the user's oracle room (it is not a member or was refused)",
61
61
  'room_unavailable',
62
62
  );
63
63
  return null;
@@ -23,11 +23,11 @@ interface ReceiptRow extends Record<string, string | number | null> {
23
23
  /** 1 once the user message was mirrored (or there is none to mirror). */
24
24
  user_mirrored: number;
25
25
  /**
26
- * 0 while the reply is owed, 1 once it was mirrored into the Companion
26
+ * 0 while the reply is owed, 1 once it was mirrored into the user's oracle
27
27
  * room, 2 when the run ended without a reply to mirror.
28
28
  */
29
29
  reply_mirrored: number;
30
- /** 1 while a mirror of this request is owed to the Companion room. */
30
+ /** 1 while a mirror of this request is owed to the user's oracle room. */
31
31
  mirror_pending: number;
32
32
  /** Failed mirror attempts since the last success. */
33
33
  mirror_failures: number;
@@ -71,7 +71,7 @@ export interface ChannelTurnsHost {
71
71
  /**
72
72
  * Throws `StaleSessionError` when the object holds no such session (it
73
73
  * was deleted), any other `ChannelError` when it is not the user's
74
- * session in the encrypted Companion room.
74
+ * session in the encrypted oracle room.
75
75
  */
76
76
  assertSession(identity: TurnIdentity, sessionId: string): Promise<void>;
77
77
  /**
@@ -86,7 +86,7 @@ export interface ChannelTurnsHost {
86
86
  begin(runId: string, request: TurnRequest): Promise<RunRecord>;
87
87
  /** The turn a run was admitted with, read back from its stored request. */
88
88
  requestOf(record: RunRecord): TurnRequest;
89
- /** Post one message into the Companion room; rejects when it could not. */
89
+ /** Post one message into the user's oracle room; rejects when it could not. */
90
90
  mirror(
91
91
  request: TurnRequest,
92
92
  text: string,
@@ -119,7 +119,7 @@ type Received =
119
119
  * (gateway RPCs and a homeserver read);
120
120
  * 3. under the lock again, SQLite only: bind the session and begin the run.
121
121
  *
122
- * The Companion room mirrors (the user message, then the reply) are owed by
122
+ * The user's oracle room mirrors (the user message, then the reply) are owed by
123
123
  * the receipt and sent in the background, never awaited by a turn or a poll.
124
124
  */
125
125
  export class ChannelTurns {
@@ -257,7 +257,7 @@ export class ChannelTurns {
257
257
  if (stale)
258
258
  throw new ChannelError(
259
259
  409,
260
- 'The Companion session of this channel no longer exists; send the request again without sessionId',
260
+ 'The oracle session of this channel no longer exists; send the request again without sessionId',
261
261
  'session_conflict',
262
262
  );
263
263
  throw error;
@@ -294,7 +294,7 @@ export class ChannelTurns {
294
294
  }
295
295
 
296
296
  /**
297
- * A run ended: mirror what it still owes the Companion room (its reply)
297
+ * A run ended: mirror what it still owes the user's oracle room (its reply)
298
298
  * in the background. The receipt records each mirror, so nothing is sent
299
299
  * twice; a failed one is retried by the housekeeping alarm.
300
300
  */
@@ -318,7 +318,7 @@ export class ChannelTurns {
318
318
 
319
319
  /**
320
320
  * The session was deleted: release every binding that pointed at it, so
321
- * the binding's next message opens a new Companion session. Returns
321
+ * the binding's next message opens a new oracle session. Returns
322
322
  * whether a binding was released.
323
323
  */
324
324
  forgetSession(sessionId: string): Promise<boolean> {
@@ -621,7 +621,7 @@ export class ChannelTurns {
621
621
  }
622
622
  return this.respond(input, receipt, session, record);
623
623
  }
624
- // The run owes its user message to the Companion room from here on;
624
+ // The run owes its user message to the user's oracle room from here on;
625
625
  // the mirror pass sends it, the turn never waits for it.
626
626
  await this.db.run(
627
627
  `UPDATE channel_requests SET session_id = ?, user_mirrored = 0, reply_mirrored = 0,
@@ -710,7 +710,7 @@ export class ChannelTurns {
710
710
  }
711
711
 
712
712
  /**
713
- * One mirror pass over the receipts that owe the Companion room a
713
+ * One mirror pass over the receipts that owe the user's oracle room a
714
714
  * message. Sessions are mirrored in parallel; within a session strictly
715
715
  * in receipt order, the user message before its reply, and a mirror that
716
716
  * fails holds the session's later ones back until it succeeds or is
@@ -222,6 +222,7 @@ export function createCapabilityRouter(
222
222
  `capability=${verdict.capability ?? '-'} ` +
223
223
  `capabilityConfidence=${verdict.capabilityConfidence?.toFixed(2) ?? '-'} ` +
224
224
  `wouldPreload=${list(verdict.preload)} reason=${verdict.reason} ` +
225
+ `calibrationProfile=${verdict.calibrationProfileRef ?? '-'} ` +
225
226
  `latencyMs=${now() - started} provider=${evaluation.provider} model=${evaluation.model}`,
226
227
  );
227
228
  turn.onShadowVerdict?.(verdict.preload);
@@ -135,7 +135,10 @@ export function resolveDeliveryProfile(
135
135
  const surface =
136
136
  turn.client === 'matrix'
137
137
  ? 'matrix'
138
- : turn.channel?.provider && DEFAULT_LIMITS[turn.channel.provider]
138
+ : // An unknown provider id uses the generic profile. `hasOwn`: a
139
+ // provider id such as `constructor` must not hit the prototype.
140
+ turn.channel?.provider &&
141
+ Object.hasOwn(DEFAULT_LIMITS, turn.channel.provider)
139
142
  ? turn.channel.provider
140
143
  : 'generic';
141
144
  const merged: ChatLimits = {
@@ -46,7 +46,7 @@ function linkLabel(title: string): string {
46
46
  }
47
47
 
48
48
  /**
49
- * The plan as one Markdown message: what the Companion room mirror of a
49
+ * The plan as one Markdown message: what the user's oracle room mirror of a
50
50
  * channel turn and gateways that predate plans receive. Never stored as a
51
51
  * run's text, which stays the model's own.
52
52
  */
@@ -8,7 +8,10 @@ import type {
8
8
  ChannelTurnOutcome,
9
9
  } from '../channels/contract';
10
10
  import type { TaskExecutionProfile } from '../core/execution-profile';
11
- import type { CapabilityRouterMode } from '@ixo/common/ai/decisions';
11
+ import type {
12
+ CapabilityRouterMode,
13
+ DecisionProviderName,
14
+ } from '@ixo/common/ai/decisions';
12
15
  import type { TranscriptPageOptions } from './transcript';
13
16
  import type { TurnUsage } from '../core/turn-budget';
14
17
  import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
@@ -62,6 +65,7 @@ export interface OracleWorkerEnv {
62
65
  UCAN_AUTH_MAX_TTL_SECONDS?: string;
63
66
  /** `true` restores the legacy bare-delegation authentication (off by default). */
64
67
  UCAN_ALLOW_BARE_DELEGATION_AUTH?: string;
68
+ /** The channel service DID, or a comma-separated list of them. */
65
69
  CHANNEL_SERVICE_DID?: string;
66
70
  AUTH_HUB?: Fetcher;
67
71
  AUTH_HUB_URL?: string;
@@ -273,10 +277,16 @@ export interface OracleWorkerEnv {
273
277
  NEBIUS_API_KEY?: string;
274
278
 
275
279
  // --- decisions ----------------------------------------------------------
276
- /** Bounded semantic Decision provider; unset leaves Decisions unconfigured. `openrouter-jev` reuses `OPEN_ROUTER_API_KEY`. */
277
- DECISION_PROVIDER?: 'cloudflare-jev' | 'openrouter-jev';
278
- /** Jev model id override (`typesafe/jev` on Workers AI, `typesafe/jev-1.13` on OpenRouter). */
280
+ /** Bounded semantic Decision provider; unset leaves Decisions unconfigured. `openrouter-jev` reuses `OPEN_ROUTER_API_KEY`; the Databricks lanes need `DATABRICKS_WORKSPACE_URL` + `DATABRICKS_TOKEN`. */
281
+ DECISION_PROVIDER?: DecisionProviderName;
282
+ /** Model id override (`typesafe/jev` on Workers AI, `typesafe/jev-1.13` on OpenRouter, the Unity Catalog model service `system.ai.openjev-qwen35-4b` for `databricks-systemone`); not used by `databricks-ai-decide`. */
279
283
  DECISION_MODEL?: string;
284
+ /** Databricks workspace origin (`https://<host>`, no path or query) for both Databricks lanes. */
285
+ DATABRICKS_WORKSPACE_URL?: string;
286
+ /** Secret. Databricks workspace token for both Databricks lanes (sent as HTTP Basic `token:<token>`). */
287
+ DATABRICKS_TOKEN?: string;
288
+ /** `ai_decide` function API version for `databricks-ai-decide` (default and only accepted value `1.0`). */
289
+ DATABRICKS_AI_DECIDE_VERSION?: string;
280
290
  /** Cloudflare account for `cloudflare-jev` over the REST API; not needed when the `AI` binding is declared. */
281
291
  CLOUDFLARE_ACCOUNT_ID?: string;
282
292
  /** Workers AI token for `cloudflare-jev` over the REST API; not needed when the `AI` binding is declared. */
@@ -354,7 +364,8 @@ export interface TurnIdentity {
354
364
  /** Request the shell / gateway sends to `UserOracleDO` to run one turn. */
355
365
  export interface TurnRequest {
356
366
  channel?: {
357
- provider: 'whatsapp';
367
+ /** A provider id (`/^[a-z][a-z0-9_-]{0,31}$/`), e.g. `whatsapp`. */
368
+ provider: string;
358
369
  bindingId: string;
359
370
  remoteMessageRef: string;
360
371
  };
@@ -43,7 +43,7 @@ export interface MirrorSend {
43
43
  threadId: string;
44
44
  txnId: string;
45
45
  origin?: {
46
- provider: 'whatsapp';
46
+ provider: string;
47
47
  bindingId: string;
48
48
  remoteMessageRef: string;
49
49
  };
@@ -1734,7 +1734,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1734
1734
  if (!roomId)
1735
1735
  throw new ChannelError(
1736
1736
  428,
1737
- 'Companion room is not ready',
1737
+ "The user's oracle room is not ready",
1738
1738
  'room_not_ready',
1739
1739
  );
1740
1740
  return (await this.createSession(identity, { roomId, markerTxnId }))
@@ -1753,13 +1753,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1753
1753
  )
1754
1754
  throw new ChannelError(
1755
1755
  404,
1756
- 'Companion session not found',
1756
+ 'Oracle session not found',
1757
1757
  'session_not_found',
1758
1758
  );
1759
1759
  if (!(await this.roomIsEncrypted(roomId)))
1760
1760
  throw new ChannelError(
1761
1761
  409,
1762
- 'Companion room must be encrypted',
1762
+ "The user's oracle room must be encrypted",
1763
1763
  'room_not_encrypted',
1764
1764
  );
1765
1765
  },
@@ -2352,7 +2352,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2352
2352
  if (next !== null) deadlines.push(Math.max(next, now + 1000));
2353
2353
  }
2354
2354
 
2355
- // Channel requests: retry the Companion room mirrors that are due, and
2355
+ // Channel requests: retry the user's oracle room mirrors that are due, and
2356
2356
  // prune (bounded) receipts that never started a run and old run
2357
2357
  // tombstones (channels/turns.ts).
2358
2358
  if (this.channelTurns) {
@@ -4763,7 +4763,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4763
4763
  outcome: RunOutcome,
4764
4764
  ): Promise<void> {
4765
4765
  // A channel run's reply (or the end of a run without one) is mirrored
4766
- // into the Companion room in the background: never awaited here, so
4766
+ // into the user's oracle room in the background: never awaited here, so
4767
4767
  // the session's next queued run starts at once. Without the handle (a
4768
4768
  // reset dropped it mid-run) the next poll or alarm mirrors it.
4769
4769
  if (record.client === 'channel') this.channelTurns?.deliverReply();
package/src/shell/app.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  authenticateChannel,
3
3
  assertActiveChannelBindingCached,
4
4
  channelAuthConfig,
5
+ parseChannelServiceDids,
5
6
  } from '../channels/auth';
6
7
  import {
7
8
  ChannelError,
@@ -418,7 +419,7 @@ export function createShell(
418
419
 
419
420
  app.post('/channels/turn', async (c) => {
420
421
  try {
421
- if (!c.env.CHANNEL_SERVICE_DID)
422
+ if (parseChannelServiceDids(c.env.CHANNEL_SERVICE_DID).length === 0)
422
423
  throw new ChannelError(
423
424
  503,
424
425
  'Channels are not configured',