@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.
@@ -1,14 +1,17 @@
1
1
  import { ChannelTurns } from '../channels/turns';
2
2
  import { assertChannelAttemptAllowed } from '../channels/auth';
3
3
  import {
4
+ CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
4
5
  ChannelError,
5
6
  channelRequestHash,
6
7
  channelOrigin,
7
8
  delegationRequiredError,
8
9
  requireChannelDelegation,
10
+ StaleSessionError,
9
11
  type ChannelTurnInput,
10
12
  type ChannelTurnOutcome,
11
13
  } from '../channels/contract';
14
+ import { classifyChannelFailure } from '../channels/failures';
12
15
  /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
13
16
  /**
14
17
  * `UserOracleDO` — one Durable Object per (user DID, oracle).
@@ -498,6 +501,8 @@ const META_LAST_ACCESS = 'meta:lastAccessAt';
498
501
  const META_DELEGATION = 'meta:delegation';
499
502
  /** How long a "no deposited delegation" verdict is trusted before re-checking the room. */
500
503
  const DELEGATION_MISS_TTL_MS = 60_000;
504
+ /** Storage key prefix: a room this object found end-to-end encrypted. */
505
+ const ROOM_ENCRYPTED_PREFIX = 'room-encrypted:';
501
506
  const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
502
507
  /** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
503
508
  const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
@@ -862,6 +867,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
862
867
  private readonly sessionRooms = new Map<string, string>();
863
868
  /** The user's main oracle room, once resolved (the session list is scoped to it). */
864
869
  private mainRoomId: string | null = null;
870
+ /** Rooms known to be end-to-end encrypted (see `roomIsEncrypted`). */
871
+ private readonly encryptedRooms = new Set<string>();
865
872
  /** The throttled `delegation_required` prompt (see reauth-prompt.ts). */
866
873
  private reauthPrompter: ReauthPrompter | null = null;
867
874
  /** Room turns running right now, by Matrix event id: a gateway that asks again attaches instead of re-running. */
@@ -1165,9 +1172,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1165
1172
  * this object's cache, then the `ucan_delegation` room state the client
1166
1173
  * deposited. Nothing found → plugins degrade exactly as before (no
1167
1174
  * downstream auth), re-checked after `DELEGATION_MISS_TTL_MS`.
1175
+ *
1176
+ * `minRemainingSeconds` (the channel path's margin): a stored copy with
1177
+ * less than that left is held, but the room state is read anyway, at
1178
+ * once and without the miss throttle — a newer deposit whose push to
1179
+ * this object failed is there — and adopted when it outlives the held
1180
+ * copy. The throttle stays for true misses (nothing stored).
1168
1181
  */
1169
- private async hydrateDelegation(userDid: string): Promise<void> {
1182
+ private async hydrateDelegation(
1183
+ userDid: string,
1184
+ options: { minRemainingSeconds?: number } = {},
1185
+ ): Promise<void> {
1170
1186
  const nowSeconds = Math.floor(Date.now() / 1000);
1187
+ const margin = options.minRemainingSeconds ?? 0;
1171
1188
  const live = (
1172
1189
  d: StoredDelegation | null | undefined,
1173
1190
  ): d is StoredDelegation =>
@@ -1175,14 +1192,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1175
1192
  (typeof d?.expiration !== 'number' || d.expiration > nowSeconds);
1176
1193
  const cached =
1177
1194
  await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
1195
+ let held: CachedDelegation | null = null;
1178
1196
  if (live(cached)) {
1179
- this.delegations.set(
1180
- userDid,
1181
- await this.delegationRecord(cached.raw, cached.expiration),
1182
- );
1183
- return;
1197
+ held = await this.delegationRecord(cached.raw, cached.expiration);
1198
+ this.delegations.set(userDid, held);
1199
+ if (
1200
+ margin === 0 ||
1201
+ (typeof held.expiration === 'number' &&
1202
+ held.expiration > nowSeconds + margin)
1203
+ )
1204
+ return;
1184
1205
  }
1185
- if (Date.now() < this.delegationMissUntil) return;
1206
+ if (!held && Date.now() < this.delegationMissUntil) return;
1186
1207
  try {
1187
1208
  const room = await this.gateway.resolveUserRoom(userDid);
1188
1209
  const json = room
@@ -1209,15 +1230,23 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1209
1230
  : {}),
1210
1231
  }
1211
1232
  : null;
1212
- if (live(deposited)) {
1213
- this.delegations.set(
1214
- userDid,
1215
- await this.delegationRecord(deposited.raw, deposited.expiration),
1216
- );
1217
- await this.ctx.storage.put(META_DELEGATION, deposited);
1218
- console.log(
1219
- `[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
1233
+ if (live(deposited) && deposited.raw !== held?.raw) {
1234
+ const record = await this.delegationRecord(
1235
+ deposited.raw,
1236
+ deposited.expiration,
1220
1237
  );
1238
+ // A held copy is replaced only by a deposit that outlives it.
1239
+ if (
1240
+ !held ||
1241
+ (typeof record.expiration === 'number' &&
1242
+ record.expiration > (held.expiration ?? 0))
1243
+ ) {
1244
+ this.delegations.set(userDid, record);
1245
+ await this.ctx.storage.put(META_DELEGATION, deposited);
1246
+ console.log(
1247
+ `[user-do] delegation for ${userDid} loaded from the deposited room state (${held ? 'the held copy was inside the margin' : 'header-less turn'})`,
1248
+ );
1249
+ }
1221
1250
  return;
1222
1251
  }
1223
1252
  } catch (err) {
@@ -1225,7 +1254,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1225
1254
  `[user-do] could not load ${userDid}'s deposited delegation: ${err instanceof Error ? err.message : String(err)}`,
1226
1255
  );
1227
1256
  }
1228
- this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
1257
+ if (!held) this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
1229
1258
  }
1230
1259
 
1231
1260
  /**
@@ -1701,57 +1730,58 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1701
1730
 
1702
1731
  this.channelTurns = new ChannelTurns(liveDb, {
1703
1732
  createSession: async (identity, markerTxnId) => {
1704
- const room = await this.gateway.resolveUserRoom(identity.userDid);
1705
- if (!room)
1733
+ const roomId = await this.resolveMainRoomId(identity.userDid);
1734
+ if (!roomId)
1706
1735
  throw new ChannelError(
1707
1736
  428,
1708
- 'Companion room is not ready',
1737
+ "The user's oracle room is not ready",
1709
1738
  'room_not_ready',
1710
1739
  );
1711
- return (
1712
- await this.createSession(identity, {
1713
- roomId: room.roomId,
1714
- markerTxnId,
1715
- })
1716
- ).sessionId;
1740
+ return (await this.createSession(identity, { roomId, markerTxnId }))
1741
+ .sessionId;
1717
1742
  },
1718
1743
  assertSession: async (identity, sessionId) => {
1719
1744
  const session = await this.sessions!.getSession(sessionId);
1720
- const room = await this.gateway.resolveUserRoom(identity.userDid);
1745
+ // Deleted (or never created here): admission releases the
1746
+ // binding's hold on it instead of refusing for ever.
1747
+ if (!session) throw new StaleSessionError();
1748
+ const roomId = await this.resolveMainRoomId(identity.userDid);
1721
1749
  if (
1722
- !session ||
1723
1750
  !sessionId.startsWith('$') ||
1724
- !room ||
1725
- session.roomId !== room.roomId
1751
+ !roomId ||
1752
+ session.roomId !== roomId
1726
1753
  )
1727
1754
  throw new ChannelError(
1728
1755
  404,
1729
- 'Companion session not found',
1756
+ 'Oracle session not found',
1730
1757
  'session_not_found',
1731
1758
  );
1732
- const encryption = await this.gateway.getRoomStateEvent(
1733
- room.roomId,
1734
- 'm.room.encryption',
1735
- );
1736
- const parsed: unknown = encryption ? JSON.parse(encryption) : null;
1737
- if (
1738
- !parsed ||
1739
- typeof parsed !== 'object' ||
1740
- !('algorithm' in parsed) ||
1741
- parsed.algorithm !== 'm.megolm.v1.aes-sha2'
1742
- )
1759
+ if (!(await this.roomIsEncrypted(roomId)))
1743
1760
  throw new ChannelError(
1744
1761
  409,
1745
- 'Companion room must be encrypted',
1762
+ "The user's oracle room must be encrypted",
1746
1763
  'room_not_encrypted',
1747
1764
  );
1748
1765
  },
1749
- // Checked again as the run starts: admission may have waited.
1750
- requireDelegation: async (identity) =>
1751
- requireChannelDelegation(
1752
- this.delegations.get(identity.userDid),
1753
- Math.floor(Date.now() / 1000),
1754
- ),
1766
+ // A new request only (a poll of an existing run is never refused
1767
+ // for it); checked again as the run starts. A held delegation
1768
+ // inside the margin re-reads the deposited room state first.
1769
+ requireDelegation: async (identity) => {
1770
+ try {
1771
+ requireChannelDelegation(
1772
+ this.delegations.get(identity.userDid),
1773
+ Math.floor(Date.now() / 1000),
1774
+ );
1775
+ } catch {
1776
+ await this.hydrateDelegation(identity.userDid, {
1777
+ minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
1778
+ });
1779
+ requireChannelDelegation(
1780
+ this.delegations.get(identity.userDid),
1781
+ Math.floor(Date.now() / 1000),
1782
+ );
1783
+ }
1784
+ },
1755
1785
  getRun: (runId) => this.runStore!.get(runId),
1756
1786
  getPlan: (runId) => this.runStore!.getPlan(runId),
1757
1787
  wasPruned: (runId) => this.runStore!.wasChannelRunPruned(runId),
@@ -1772,12 +1802,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1772
1802
  this.ctx.waitUntil(live.done.catch(() => undefined));
1773
1803
  return live.record;
1774
1804
  },
1805
+ requestOf: (record) =>
1806
+ (JSON.parse(record.request) as StoredRunRequest).turn,
1775
1807
  mirror: (request, text, author) =>
1776
1808
  this.replayToRoom(request, text, author),
1777
1809
  changed: async () => {
1778
1810
  this.markDirty();
1779
1811
  await this.ctx.storage.sync();
1780
1812
  },
1813
+ scheduleRetry: (at) => this.ctx.waitUntil(this.requestHousekeeping(at)),
1814
+ keepAlive: (work) => this.ctx.waitUntil(work),
1815
+ pruneTombstones: (before, limit) =>
1816
+ this.runStore!.pruneChannelTombstones(before, limit),
1781
1817
  });
1782
1818
 
1783
1819
  const core = this.core;
@@ -2316,6 +2352,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2316
2352
  if (next !== null) deadlines.push(Math.max(next, now + 1000));
2317
2353
  }
2318
2354
 
2355
+ // Channel requests: retry the user's oracle room mirrors that are due, and
2356
+ // prune (bounded) receipts that never started a run and old run
2357
+ // tombstones (channels/turns.ts).
2358
+ if (this.channelTurns) {
2359
+ try {
2360
+ const next = await this.channelTurns.housekeeping();
2361
+ if (next !== null) deadlines.push(Math.max(next, now + 1000));
2362
+ } catch (err) {
2363
+ console.error(
2364
+ `[user-do] channel housekeeping failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
2365
+ );
2366
+ }
2367
+ }
2368
+
2319
2369
  // Legacy-blob compaction: one bounded batch per tick until the whole
2320
2370
  // file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
2321
2371
  // flight holds a snapshot of the file, which the compactor's swap-in
@@ -2663,24 +2713,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2663
2713
  'channel_grant_not_tool_authority',
2664
2714
  );
2665
2715
  // The shell (the only caller) checked the binding just before.
2666
- // The delegation is checked BEFORE the object boots: a cold boot
2667
- // loads the owner copy under it, and a user without a usable one
2668
- // must get `delegation_required`, not a boot failure. Hydration
2669
- // reads the cached copy, then the deposited room state (throttled
2670
- // after a miss); an in-memory one that no longer passes is
2671
- // re-read too, in case a newer deposit did not reach this object.
2672
- const nowSeconds = Math.floor(Date.now() / 1000);
2673
- try {
2674
- requireChannelDelegation(
2675
- this.delegations.get(identity.userDid),
2676
- nowSeconds,
2677
- );
2678
- } catch {
2679
- await this.hydrateDelegation(identity.userDid);
2680
- requireChannelDelegation(
2681
- this.delegations.get(identity.userDid),
2682
- nowSeconds,
2683
- );
2716
+ // The 900 s margin is checked by admission, for a NEW request only
2717
+ // (and again as its run starts): a poll of a run that exists
2718
+ // answers its status whatever the delegation's state. Before a
2719
+ // COLD object boots, though, a delegation must be live (present,
2720
+ // unexpired): the boot may load the owner copy under it, and a user
2721
+ // without one must get `delegation_required`, not a boot failure.
2722
+ // Hydration reads the stored copy, then the deposited room state
2723
+ // (re-read when the stored copy is inside the margin).
2724
+ if (!this.db) {
2725
+ const live = (): boolean => {
2726
+ const held = this.delegations.get(identity.userDid);
2727
+ return (
2728
+ Boolean(held?.raw) &&
2729
+ (typeof held?.expiration !== 'number' ||
2730
+ held.expiration > Math.floor(Date.now() / 1000))
2731
+ );
2732
+ };
2733
+ if (!live())
2734
+ await this.hydrateDelegation(identity.userDid, {
2735
+ minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
2736
+ });
2737
+ if (!live()) throw delegationRequiredError();
2684
2738
  }
2685
2739
  try {
2686
2740
  await this.ready(identity);
@@ -2699,17 +2753,48 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2699
2753
  result: await this.channelTurns!.submit(identity, input, requestHash),
2700
2754
  };
2701
2755
  } catch (error) {
2702
- if (error instanceof ChannelError)
2756
+ // A failure retrying cannot fix (the room refused the oracle, the
2757
+ // file storage rejected it) is a final refusal, not a 503.
2758
+ const refusal =
2759
+ error instanceof ChannelError ? error : classifyChannelFailure(error);
2760
+ if (refusal)
2703
2761
  return {
2704
2762
  ok: false,
2705
- status: error.status,
2706
- code: error.code,
2707
- message: error.message,
2763
+ status: refusal.status,
2764
+ code: refusal.code,
2765
+ message: refusal.message,
2708
2766
  };
2709
2767
  throw error;
2710
2768
  }
2711
2769
  }
2712
2770
 
2771
+ /**
2772
+ * A poll of a channel request whose run exists, answered by a warm
2773
+ * object from SQLite without booting, admitting or waiting on the
2774
+ * admission lock. `null` when the object is cold or the request has no
2775
+ * run yet: the shell then checks the binding with the Auth Hub and
2776
+ * calls `channelTurn`. A binding revoked meanwhile is still enforced
2777
+ * where it matters: before a queued or recovered attempt executes.
2778
+ */
2779
+ async channelPoll(
2780
+ identity: TurnIdentity,
2781
+ input: ChannelTurnInput,
2782
+ requestHash: string,
2783
+ ): Promise<ChannelTurnOutcome | null> {
2784
+ if (identity.ucanDelegation || !this.initPromise || this.evicting)
2785
+ return null;
2786
+ try {
2787
+ await this.initPromise;
2788
+ } catch {
2789
+ return null;
2790
+ }
2791
+ if (this.userDid !== identity.userDid || !this.channelTurns) return null;
2792
+ const result = await this.channelTurns.poll(identity, input, requestHash);
2793
+ if (!result) return null;
2794
+ await this.recordAccess();
2795
+ return { ok: true, result };
2796
+ }
2797
+
2713
2798
  async listSessions(
2714
2799
  identity: TurnIdentity,
2715
2800
  o: { limit?: number; offset?: number } = {},
@@ -2745,6 +2830,37 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2745
2830
  return room?.roomId ?? null;
2746
2831
  }
2747
2832
 
2833
+ /**
2834
+ * Whether the room is end-to-end encrypted (Megolm). Encryption cannot
2835
+ * be turned off in Matrix, so a positive answer is remembered for good,
2836
+ * in memory and in storage (`room-encrypted:<roomId>`): the homeserver
2837
+ * is asked once per room, not once per channel admission. A negative
2838
+ * answer is never remembered.
2839
+ */
2840
+ private async roomIsEncrypted(roomId: string): Promise<boolean> {
2841
+ if (this.encryptedRooms.has(roomId)) return true;
2842
+ const key = `${ROOM_ENCRYPTED_PREFIX}${roomId}`;
2843
+ if ((await this.ctx.storage.get<boolean>(key)) === true) {
2844
+ this.encryptedRooms.add(roomId);
2845
+ return true;
2846
+ }
2847
+ const encryption = await this.gateway.getRoomStateEvent(
2848
+ roomId,
2849
+ 'm.room.encryption',
2850
+ );
2851
+ const parsed: unknown = encryption ? JSON.parse(encryption) : null;
2852
+ const encrypted =
2853
+ typeof parsed === 'object' &&
2854
+ parsed !== null &&
2855
+ 'algorithm' in parsed &&
2856
+ parsed.algorithm === 'm.megolm.v1.aes-sha2';
2857
+ if (encrypted) {
2858
+ this.encryptedRooms.add(roomId);
2859
+ await this.ctx.storage.put(key, true);
2860
+ }
2861
+ return encrypted;
2862
+ }
2863
+
2748
2864
  async deleteSession(
2749
2865
  identity: TurnIdentity,
2750
2866
  sessionId: string,
@@ -4646,16 +4762,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4646
4762
  record: RunRecord,
4647
4763
  outcome: RunOutcome,
4648
4764
  ): Promise<void> {
4649
- if (record.client === 'channel' && outcome.status === 'finished') {
4650
- const stored = JSON.parse(record.request) as StoredRunRequest;
4651
- // Without the handle (a reset dropped it mid-run) the next poll of
4652
- // the request delivers the reply instead.
4653
- await this.channelTurns?.deliverReply(
4654
- stored.turn,
4655
- record.runId,
4656
- outcome.text,
4657
- );
4658
- }
4765
+ // A channel run's reply (or the end of a run without one) is mirrored
4766
+ // into the user's oracle room in the background: never awaited here, so
4767
+ // the session's next queued run starts at once. Without the handle (a
4768
+ // reset dropped it mid-run) the next poll or alarm mirrors it.
4769
+ if (record.client === 'channel') this.channelTurns?.deliverReply();
4659
4770
  if (!record.taskRunId || !this.taskScheduler) return;
4660
4771
  if (this.taskScheduler.isRunActive(record.taskRunId)) return;
4661
4772
  if (outcome.status === 'finished')
package/src/shell/app.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  authenticateChannel,
3
- assertActiveChannelBinding,
3
+ assertActiveChannelBindingCached,
4
4
  channelAuthConfig,
5
+ parseChannelServiceDids,
5
6
  } from '../channels/auth';
6
7
  import {
7
8
  ChannelError,
@@ -212,7 +213,7 @@ export function isDidShaped(value: string): boolean {
212
213
  */
213
214
  export function rateLimitKey(
214
215
  env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
215
- scope: 'user' | 'artifact' | 'socket' | 'status' | 'start',
216
+ scope: 'user' | 'channel' | 'artifact' | 'socket' | 'status' | 'start',
216
217
  subject: string,
217
218
  ): string {
218
219
  return `${env.ORACLE_DID}|${scope}|${subject}`;
@@ -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',
@@ -442,21 +443,28 @@ export function createShell(
442
443
  );
443
444
  // Limited as soon as the user is proven, before the Auth Hub round
444
445
  // trip: a flood or a tight poll must not cost one call there each.
446
+ // Channel traffic has its own budget (`channel` scope), apart from
447
+ // the user's Portal requests: polls never push the Portal into 429,
448
+ // nor the other way round.
445
449
  if (
446
450
  c.env.RATE_LIMIT &&
447
451
  !(
448
452
  await c.env.RATE_LIMIT.limit({
449
- key: rateLimitKey(c.env, 'user', identity.userDid),
453
+ key: rateLimitKey(c.env, 'channel', identity.userDid),
450
454
  })
451
455
  ).success
452
456
  )
453
457
  throw new ChannelError(429, 'Too many requests', 'rate_limited');
454
- await assertActiveChannelBinding(identity, c.env);
455
- const outcome = await userStub(c.env, identity.userDid).channelTurn(
456
- identity,
457
- parsed.data,
458
- await channelRequestHash(raw),
459
- );
458
+ const stub = userStub(c.env, identity.userDid);
459
+ const requestHash = await channelRequestHash(raw);
460
+ // A poll of a run that exists is answered by the (warm) object with no
461
+ // Auth Hub call; anything else is checked with the Auth Hub (an
462
+ // "active" verdict is cached per isolate for 60 s) and admitted.
463
+ let outcome = await stub.channelPoll(identity, parsed.data, requestHash);
464
+ if (!outcome) {
465
+ await assertActiveChannelBindingCached(identity, c.env);
466
+ outcome = await stub.channelTurn(identity, parsed.data, requestHash);
467
+ }
460
468
  if (!outcome.ok)
461
469
  return c.json(
462
470
  { code: outcome.code, message: outcome.message },