@ixo/oracle-runtime-workers 0.2.0 → 0.4.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/README.md CHANGED
@@ -11,6 +11,8 @@ import { createOracleWorker, WeatherPlugin } from '@ixo/oracle-runtime-workers';
11
11
  const oracle = createOracleWorker({
12
12
  config: { name: 'My Oracle', org: 'IXO', description: '…' },
13
13
  plugins: [new WeatherPlugin()],
14
+ // Optional, as on Node: retune a loaded plugin's manifest without forking it.
15
+ manifestOverrides: { weather: { visibility: 'always' } },
14
16
  });
15
17
 
16
18
  export const { UserOracleDO, MatrixGatewayDO } = oracle;
@@ -48,7 +50,10 @@ graph LR
48
50
  media, the account-room secrets key, the plugins' second device.
49
51
  - **The user owns the file.** The object holds a working copy; the durable
50
52
  file is `/.oracles/<oracleDid>/state.db.gz` in the user's IXO VFS,
51
- exported on a debounced alarm and re-imported on a cold boot.
53
+ exported on a debounced alarm and re-imported on a cold boot. The oracle
54
+ reaches it with the one delegation the user deposits for it
55
+ (`POST /delegation`), which must carry `ixo:filesystem/.oracles` next to
56
+ the plugin grants — see [architecture](docs/architecture.md#self-sovereign-storage).
52
57
  - **Two scripts in production.** The gateway runs in its own Worker script
53
58
  so its memory footprint never competes with user objects; the scripts
54
59
  talk over cross-script Durable Object bindings.
@@ -62,7 +67,9 @@ graph LR
62
67
  (`MATRIX_ORACLE_ADMIN_PASSWORD`, `MATRIX_RECOVERY_PHRASE`,
63
68
  `MATRIX_VALUE_PIN`).
64
69
  3. Deploy the oracle script with its secrets (`OPEN_ROUTER_API_KEY`, the
65
- same Matrix secrets, `ORACLE_SIGNING_MNEMONIC`).
70
+ same Matrix secrets, and `ORACLE_SIGNING_MNEMONIC` unless the account
71
+ room already holds the mnemonic the Node runtime or the CLI provisioned —
72
+ then the runtime reads it from there).
66
73
  4. `POST /matrix/start`, then `GET /matrix/status` until `running` and
67
74
  `cryptoReady` are true.
68
75
  5. Run the feature matrix against it (see [testing](docs/testing.md)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": {
@@ -14,12 +14,6 @@
14
14
  "engines": {
15
15
  "node": ">=22"
16
16
  },
17
- "scripts": {
18
- "typecheck": "tsc --noEmit",
19
- "test": "vitest run",
20
- "test:core": "vitest run --config vitest.core.config.ts",
21
- "lint": "eslint 'src/**/*.ts'"
22
- },
23
17
  "main": "src/index.ts",
24
18
  "types": "src/index.ts",
25
19
  "exports": {
@@ -41,7 +35,6 @@
41
35
  "@ixo/editor": "6.0.1",
42
36
  "@ixo/matrix-bot-workers-sdk": "0.1.3",
43
37
  "@ixo/matrix-crdt": "^1.2.5",
44
- "@ixo/ucan": "workspace:*",
45
38
  "@langchain/core": "1.1.48",
46
39
  "@langchain/langgraph": "1.3.2",
47
40
  "@langchain/langgraph-checkpoint": "1.0.2",
@@ -60,18 +53,25 @@
60
53
  "superjson": "1.13.3",
61
54
  "wa-sqlite": "1.0.0",
62
55
  "yjs": "^13.6.27",
63
- "zod": "^4.4.3"
56
+ "zod": "^4.4.3",
57
+ "@ixo/ucan": "2.2.0"
64
58
  },
65
59
  "devDependencies": {
66
60
  "@cloudflare/vitest-pool-workers": "^0.22.0",
67
61
  "@cloudflare/workers-types": "^5.20260825.1",
68
- "@ixo/typescript-config": "workspace:*",
69
62
  "happy-dom": "^19.0.0",
70
63
  "typescript": "^5.9.3",
71
64
  "vitest": "^4.0.0",
72
- "wrangler": "^4.125.0"
65
+ "wrangler": "^4.125.0",
66
+ "@ixo/typescript-config": "1.0.0"
73
67
  },
74
68
  "peerDependencies": {
75
69
  "typescript": ">=5.0.0"
70
+ },
71
+ "scripts": {
72
+ "typecheck": "tsc --noEmit",
73
+ "test": "vitest run",
74
+ "test:core": "vitest run --config vitest.core.config.ts",
75
+ "lint": "eslint 'src/**/*.ts'"
76
76
  }
77
- }
77
+ }
@@ -502,6 +502,16 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
502
502
  * objects fetch it once at boot and seat it into their secrets service.
503
503
  */
504
504
  getOracleSecretsKey(): Promise<JsonString | null>;
505
+ /**
506
+ * The oracle's Ed25519 UCAN signing mnemonic, read the way the Node
507
+ * runtime stores it: the account room's `ixo.room.state.secure` /
508
+ * `encrypted_mnemonic_ed_signing` state event, whose `encrypted_mnemonic`
509
+ * is AES-256-CBC encrypted with `MATRIX_VALUE_PIN`. Read-only (the Node
510
+ * runtime provisions it). Null when the env vars are unset, the event is
511
+ * missing or the PIN does not decrypt it — each logged, never thrown.
512
+ * User objects call this at boot when `ORACLE_SIGNING_MNEMONIC` is unset.
513
+ */
514
+ getOracleSigningMnemonic(): Promise<string | null>;
505
515
  }
506
516
 
507
517
  export interface BotCredentials {
@@ -36,7 +36,8 @@ function toSupportedDid(did: string): SupportedDID {
36
36
  throw new Error(`Unsupported oracle DID method: ${did}`);
37
37
  }
38
38
 
39
- function abilityCovers(granted: string, required: string): boolean {
39
+ /** Does a granted ability (`*`, `ns/*`, or an exact ability) cover `required`? */
40
+ export function abilityCovers(granted: string, required: string): boolean {
40
41
  if (granted === '*' || granted === required) return true;
41
42
  if (granted.endsWith('/*')) {
42
43
  const ns = granted.slice(0, -2);
@@ -45,8 +46,35 @@ function abilityCovers(granted: string, required: string): boolean {
45
46
  return false;
46
47
  }
47
48
 
49
+ /** One capability of a delegation, as the wire carries it. */
50
+ export interface DelegatedCapability {
51
+ can: string;
52
+ with: string;
53
+ nb?: Record<string, unknown>;
54
+ }
55
+
56
+ /**
57
+ * The capabilities a serialized delegation grants, without validating it
58
+ * (the shell validated it when it was deposited; callers that mint from it
59
+ * are checked again by the service that receives the invocation).
60
+ */
61
+ export async function listDelegationCapabilities(
62
+ delegationCar: string,
63
+ ): Promise<DelegatedCapability[]> {
64
+ const delegation = await parseDelegation(delegationCar);
65
+ return delegation.capabilities.map((c) => ({
66
+ can: c.can,
67
+ with: c.with,
68
+ ...(c.nb !== undefined && c.nb !== null
69
+ ? { nb: c.nb as Record<string, unknown> }
70
+ : {}),
71
+ }));
72
+ }
73
+
48
74
  /** Upper bound on a delegated invocation's lifetime (mirrors the Node runtime). */
49
75
  const MAX_INVOCATION_TTL_SECONDS = 60 * 60;
76
+ /** Parsed capability lists kept per raw delegation (the owner store asks per request). */
77
+ const DELEGATION_CAPABILITIES_CACHE_SIZE = 8;
50
78
 
51
79
  export class WorkersUcanService {
52
80
  private readonly serviceDidCache = new Map<
@@ -57,6 +85,7 @@ export class WorkersUcanService {
57
85
  string,
58
86
  { value: { token: string; with: string }; expiresAt: number }
59
87
  >();
88
+ private readonly capabilitiesCache = new Map<string, DelegatedCapability[]>();
60
89
 
61
90
  constructor(private readonly opts: UcanServiceOptions) {}
62
91
 
@@ -64,6 +93,21 @@ export class WorkersUcanService {
64
93
  return Boolean(this.opts.signingMnemonic);
65
94
  }
66
95
 
96
+ /** `listDelegationCapabilities`, memoised on the raw token. */
97
+ async delegationCapabilities(
98
+ delegationCar: string,
99
+ ): Promise<DelegatedCapability[]> {
100
+ const cached = this.capabilitiesCache.get(delegationCar);
101
+ if (cached) return cached;
102
+ const capabilities = await listDelegationCapabilities(delegationCar);
103
+ if (this.capabilitiesCache.size >= DELEGATION_CAPABILITIES_CACHE_SIZE) {
104
+ const oldest = this.capabilitiesCache.keys().next().value;
105
+ if (oldest !== undefined) this.capabilitiesCache.delete(oldest);
106
+ }
107
+ this.capabilitiesCache.set(delegationCar, capabilities);
108
+ return capabilities;
109
+ }
110
+
67
111
  async resolveServiceDid(serviceUrl: string): Promise<string | null> {
68
112
  try {
69
113
  const origin = new URL(serviceUrl).origin;
@@ -121,7 +121,6 @@ import {
121
121
  } from './debug-timers';
122
122
  import {
123
123
  IxoVfsOwnerStore,
124
- UCAN_STORE_DEFAULT_URLS,
125
124
  VFS_DEFAULT_BASE_URLS,
126
125
  } from '../owner-store/ixo-vfs-store';
127
126
  import type { OwnerStore } from '../owner-store/types';
@@ -529,7 +528,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
529
528
  private async ready(identity: TurnIdentity): Promise<void> {
530
529
  this.installDebugTimerTracker();
531
530
  this.adoptHibernatedSockets();
531
+ let delegationReplaced = false;
532
532
  if (identity.ucanDelegation) {
533
+ // Clients (the Portal's SDK) send their cached delegation with every
534
+ // request; a token this object has not seen is a re-authorization.
535
+ const known =
536
+ this.delegations.get(identity.userDid)?.raw ??
537
+ (await this.ctx.storage.get<StoredDelegation>(META_DELEGATION))?.raw;
538
+ delegationReplaced = known !== identity.ucanDelegation;
533
539
  this.delegations.set(identity.userDid, {
534
540
  raw: identity.ucanDelegation,
535
541
  });
@@ -557,6 +563,51 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
557
563
  );
558
564
  }
559
565
  await this.ctx.storage.put(META_LAST_ACCESS, Date.now());
566
+ if (delegationReplaced) await this.onDelegationReplaced();
567
+ }
568
+
569
+ /**
570
+ * The mnemonic the oracle signs downstream UCAN invocations with:
571
+ * `ORACLE_SIGNING_MNEMONIC` when set, otherwise the one the Node runtime
572
+ * keeps in the Matrix account room (read and decrypted by the gateway,
573
+ * memoised there). Undefined leaves the object without a signing key —
574
+ * plugins that need one degrade with their usual "no signing key" result
575
+ * — and never fails the boot.
576
+ */
577
+ private async resolveSigningMnemonic(): Promise<string | undefined> {
578
+ const fromEnv = this.env.ORACLE_SIGNING_MNEMONIC;
579
+ if (typeof fromEnv === 'string' && fromEnv.trim().length > 0)
580
+ return fromEnv;
581
+ try {
582
+ const fromRoom = await this.gateway.getOracleSigningMnemonic();
583
+ if (fromRoom) return fromRoom;
584
+ } catch (err) {
585
+ console.warn(
586
+ `[user-do] signing mnemonic unavailable from the account room: ${err instanceof Error ? err.message : String(err)}`,
587
+ );
588
+ }
589
+ console.warn(
590
+ '[user-do] no UCAN signing key: ORACLE_SIGNING_MNEMONIC is unset and the account room holds no readable mnemonic — downstream minting is off',
591
+ );
592
+ return undefined;
593
+ }
594
+
595
+ /**
596
+ * The object holds a delegation it had not seen before (a header on this
597
+ * request, or a deposit through the shell). A fresh delegation is the one
598
+ * thing that turns a "no file-storage grant" flush failure into a
599
+ * success: forget the failure streak and, with unsaved turns, flush now
600
+ * instead of at the next 10-minute retry.
601
+ */
602
+ private async onDelegationReplaced(): Promise<void> {
603
+ await this.ctx.storage.delete(META_FLUSH_FAILURES);
604
+ if (!this.db) return;
605
+ if (
606
+ !this.dirty &&
607
+ (await this.ctx.storage.get<boolean>(META_DIRTY)) === true
608
+ )
609
+ this.dirty = true;
610
+ if (this.dirty) void this.flushToOwnerStore().catch(() => undefined);
560
611
  }
561
612
 
562
613
  /**
@@ -629,22 +680,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
629
680
  this.userDid = userDid;
630
681
 
631
682
  const env = this.env;
632
- this.ucan = new WorkersUcanService({
633
- oracleDid: env.ORACLE_DID,
634
- signingMnemonic:
635
- typeof env.ORACLE_SIGNING_MNEMONIC === 'string'
636
- ? env.ORACLE_SIGNING_MNEMONIC
637
- : undefined,
638
- logger: console,
639
- });
640
- this.ownerStore = await this.createOwnerStore(userDid);
641
683
  // The gateway lazily starts on first use; make sure it is up before the
642
- // owner store or room lookups need it (idempotent, cheap once running).
684
+ // signing mnemonic, the owner store or room lookups need it (idempotent,
685
+ // cheap once running).
643
686
  await this.gateway.ensureStarted().catch((err) => {
644
687
  console.warn(
645
688
  `[user-do] matrix gateway not available yet: ${err instanceof Error ? err.message : String(err)}`,
646
689
  );
647
690
  });
691
+ this.ucan = new WorkersUcanService({
692
+ oracleDid: env.ORACLE_DID,
693
+ signingMnemonic: await this.resolveSigningMnemonic(),
694
+ logger: console,
695
+ });
696
+ this.ownerStore = await this.createOwnerStore(userDid);
648
697
 
649
698
  // Per-room JWE secrets: seat the oracle's P-256 key (fetched once from
650
699
  // the gateway, which reads the Matrix account room). Without a key the
@@ -1003,10 +1052,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1003
1052
  env.VFS_BASE_URL ??
1004
1053
  VFS_DEFAULT_BASE_URLS[network] ??
1005
1054
  VFS_DEFAULT_BASE_URLS.devnet!,
1006
- ucanStoreUrl:
1007
- env.UCAN_STORE_URL ??
1008
- UCAN_STORE_DEFAULT_URLS[network] ??
1009
- UCAN_STORE_DEFAULT_URLS.devnet!,
1055
+ // The user's one delegation to this oracle — hydrated by `ready()`
1056
+ // before boot, replaced at once by `setDelegation` / `clearDelegation`.
1057
+ delegation: () => this.delegations.get(userDid)?.raw,
1010
1058
  });
1011
1059
  return new MigratingOwnerStore({
1012
1060
  primary: vfsStore,
@@ -1506,6 +1554,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1506
1554
  at: Date.now(),
1507
1555
  ...(typeof expiration === 'number' ? { expiration } : {}),
1508
1556
  } satisfies StoredDelegation);
1557
+ await this.onDelegationReplaced();
1509
1558
  }
1510
1559
 
1511
1560
  /**
package/src/index.ts CHANGED
@@ -35,7 +35,11 @@ export * from './plugins';
35
35
  export * from './do/contracts';
36
36
  export type { OwnerStore } from './owner-store/types';
37
37
  export { MigratingOwnerStore } from './owner-store/migrating-store';
38
- export { IxoVfsOwnerStore } from './owner-store/ixo-vfs-store';
38
+ export {
39
+ IxoVfsOwnerStore,
40
+ VFS_OWNER_COPY_RESOURCE,
41
+ ownerCopyCapability,
42
+ } from './owner-store/ixo-vfs-store';
39
43
  export { MatrixMediaOwnerStore } from './owner-store/matrix-media-store';
40
44
  export { MatrixGatewayDO } from './matrix/gateway-do';
41
45
  export {
@@ -131,6 +135,16 @@ export interface CreateOracleWorkerOptions {
131
135
  config: OracleConfig;
132
136
  plugins?: OraclePlugin[];
133
137
  features?: RuntimeCoreOptions['features'];
138
+ /**
139
+ * Per-plugin manifest overrides, shallow-merged over each loaded plugin's
140
+ * own `manifest` at boot (the Node runtime's `createOracleApp` option of
141
+ * the same name): retune a bundled plugin's summary, hints or
142
+ * `visibility` — e.g. `{ portal: { visibility: 'always' } }` binds the
143
+ * Portal's browser tools on every turn instead of behind
144
+ * `load_capability`. Keys naming no loaded plugin are logged and ignored;
145
+ * the merged manifest is validated like an authored one.
146
+ */
147
+ manifestOverrides?: RuntimeCoreOptions['manifestOverrides'];
134
148
  /** Extra host routes mounted on the shell. */
135
149
  routes?: PluginRoute[];
136
150
  /** Host routes exempt from UCAN auth. */
@@ -179,6 +193,7 @@ export function createOracleWorker(
179
193
  config: opts.config,
180
194
  plugins: opts.plugins ?? [],
181
195
  features: opts.features,
196
+ manifestOverrides: opts.manifestOverrides,
182
197
  env,
183
198
  logger: console,
184
199
  });
@@ -44,6 +44,12 @@ import {
44
44
  type UserOracleObject,
45
45
  } from '../do/contracts';
46
46
  import { decryptWithPin } from '../secrets/pin-cipher';
47
+ import {
48
+ encryptedMnemonicOf,
49
+ parseSigningMnemonic,
50
+ SIGNING_MNEMONIC_STATE_KEY,
51
+ SIGNING_MNEMONIC_STATE_TYPE,
52
+ } from '../secrets/signing-mnemonic';
47
53
  import {
48
54
  IngestPipeline,
49
55
  type InboundAttachment,
@@ -202,6 +208,7 @@ export class MatrixGatewayDO
202
208
  private pluginDevice: BotCredentials | null = null;
203
209
  /** Memoised `getOracleSecretsKey` result (private JWK JSON). */
204
210
  private oracleSecretsKeyJson: string | null = null;
211
+ private oracleSigningMnemonic: string | null = null;
205
212
 
206
213
  // -------------------------------------------------------------------------
207
214
  // Configuration
@@ -644,6 +651,75 @@ export class MatrixGatewayDO
644
651
  return jwkJson;
645
652
  }
646
653
 
654
+ /**
655
+ * The oracle's UCAN signing mnemonic from the account room — the Node
656
+ * runtime's storage (`setup-claim-signing-mnemonics.ts`): state event
657
+ * `ixo.room.state.secure` / `encrypted_mnemonic_ed_signing`, content
658
+ * `{ encrypted_mnemonic: 'ivHex:cipherHex' }`, AES-256-CBC with
659
+ * `MATRIX_VALUE_PIN` (the same cipher as the secrets key above). Read-only:
660
+ * provisioning (generate, store, publish the verification method on chain)
661
+ * stays with the Node runtime and the CLI. Memoised for the life of this
662
+ * object. Every failure is logged and yields null — a user object then
663
+ * runs without a signing key, exactly as with no `ORACLE_SIGNING_MNEMONIC`.
664
+ */
665
+ async getOracleSigningMnemonic(): Promise<string | null> {
666
+ if (this.oracleSigningMnemonic !== null) return this.oracleSigningMnemonic;
667
+ const roomId = this.env.MATRIX_ACCOUNT_ROOM_ID;
668
+ const pin = this.env.MATRIX_VALUE_PIN;
669
+ if (!roomId || !pin) {
670
+ this.log(
671
+ 'warn',
672
+ 'signing mnemonic unavailable: ORACLE_SIGNING_MNEMONIC is unset and MATRIX_ACCOUNT_ROOM_ID / MATRIX_VALUE_PIN are not set',
673
+ );
674
+ return null;
675
+ }
676
+ await this.ensureStarted();
677
+ const stateJson = await this.getRoomStateEvent(
678
+ roomId,
679
+ SIGNING_MNEMONIC_STATE_TYPE,
680
+ SIGNING_MNEMONIC_STATE_KEY,
681
+ );
682
+ if (!stateJson) {
683
+ this.log(
684
+ 'warn',
685
+ `no ${SIGNING_MNEMONIC_STATE_TYPE}/${SIGNING_MNEMONIC_STATE_KEY} in the account room — set ORACLE_SIGNING_MNEMONIC or provision the mnemonic with the Node runtime / CLI; downstream UCAN minting stays off`,
686
+ );
687
+ return null;
688
+ }
689
+ const encrypted = encryptedMnemonicOf(stateJson);
690
+ if (!encrypted) {
691
+ this.log(
692
+ 'warn',
693
+ `${SIGNING_MNEMONIC_STATE_KEY} in the account room carries no encrypted_mnemonic — downstream UCAN minting stays off`,
694
+ );
695
+ return null;
696
+ }
697
+ let plain: string;
698
+ try {
699
+ plain = await decryptWithPin(encrypted, pin);
700
+ } catch (err) {
701
+ this.log(
702
+ 'error',
703
+ `could not decrypt ${SIGNING_MNEMONIC_STATE_KEY} with MATRIX_VALUE_PIN (wrong PIN?): ${err instanceof Error ? err.message : String(err)} — downstream UCAN minting stays off`,
704
+ );
705
+ return null;
706
+ }
707
+ const mnemonic = parseSigningMnemonic(plain);
708
+ if (!mnemonic) {
709
+ this.log(
710
+ 'error',
711
+ `${SIGNING_MNEMONIC_STATE_KEY} decrypted to something that is not a BIP-39 mnemonic (wrong PIN?) — downstream UCAN minting stays off`,
712
+ );
713
+ return null;
714
+ }
715
+ this.oracleSigningMnemonic = mnemonic;
716
+ this.log(
717
+ 'info',
718
+ `oracle signing mnemonic loaded from the account room (${mnemonic.split(' ').length} words)`,
719
+ );
720
+ return mnemonic;
721
+ }
722
+
647
723
  // -------------------------------------------------------------------------
648
724
  // User snapshots (SQLite files as room media) and media downloads
649
725
  // -------------------------------------------------------------------------
@@ -5,14 +5,18 @@
5
5
  * The file lives at `/.oracles/<oracleDid>/state.db.gz` in the user's own
6
6
  * namespace — they can list, download, share or delete it like any file.
7
7
  * Every request carries a fresh single-use UCAN invocation minted from the
8
- * delegation the user deposited for this oracle in the UCAN store worker
9
- * (two-hop flow, same as the Node runtime's VFS plugin):
8
+ * ONE delegation the user deposited for this oracle (`POST /delegation`,
9
+ * the `ucan_delegation` room state — the same delegation Matrix turns and
10
+ * every plugin mint from). That delegation must carry a capability over the
11
+ * user's own filesystem covering the `/.oracles` folder:
10
12
  *
11
- * store worker `GET /api/delegations?rootIssuer=<user>` (oracle self-signed)
12
- * → delegation over `ixo:filesystem` granting `can: '*'` (the VFS grant
13
- * lattice has no `fs/*` — `'*'` is the owner grant covering
14
- * read/write/list/delete)
15
- * → invocation attenuated to the one ability, proved by that delegation.
13
+ * `{ can: '*', with: 'ixo:filesystem/.oracles', nb: { hidden: ['/.oracles'] } }`
14
+ *
15
+ * (`with: 'ixo:filesystem'`, the whole personal library, also qualifies; the
16
+ * VFS grant lattice has no `fs/*`, so `'*'` is the owner grant covering
17
+ * read/write/list/delete.) The invocation is attenuated to the one ability
18
+ * and proved by that delegation. There is no other source of file access:
19
+ * without that capability the user is "not on VFS" (`VfsNoDelegationError`).
16
20
  *
17
21
  * Endpoints are the VFS worker's `/api/fs/*` (see the Node runtime's
18
22
  * `plugins/vfs/vfs-client.ts`): `GET /files?path=` (stat via a SHORT prefix —
@@ -34,7 +38,7 @@
34
38
  * `stat()` lists under the short literal `/.oracles` prefix and matches
35
39
  * the exact path client-side.
36
40
  */
37
- import type { WorkersUcanService } from '../do/ucan-service';
41
+ import { abilityCovers, type WorkersUcanService } from '../do/ucan-service';
38
42
  import { isNetworkError, withRetry } from './retry';
39
43
  import { tusUpload } from './tus-upload';
40
44
  import {
@@ -52,12 +56,15 @@ const VFS_RESOURCE = 'ixo:filesystem';
52
56
  const INVOCATION_TTL_SECONDS = 60;
53
57
  /** Root folder for per-oracle state files — also the short stat prefix. */
54
58
  const ORACLES_ROOT = '/.oracles';
59
+ /** The `with` a user's delegation needs at minimum: the personal `/.oracles` folder. */
60
+ export const VFS_OWNER_COPY_RESOURCE = `${VFS_RESOURCE}${ORACLES_ROOT}`;
55
61
 
56
62
  export const VFS_DEFAULT_BASE_URLS: Record<string, string> = {
57
63
  mainnet: 'https://vfs.ixo.earth',
58
64
  testnet: 'https://testnet.vfs.ixo.earth',
59
65
  devnet: 'https://devnet.vfs.ixo.earth',
60
66
  };
67
+ /** UCAN store worker per network — used by the VFS plugin's file tools, not by this store. */
61
68
  export const UCAN_STORE_DEFAULT_URLS: Record<string, string> = {
62
69
  mainnet: 'https://store.ucan.ixo.earth',
63
70
  testnet: 'https://testnet.store.ucan.ixo.earth',
@@ -65,32 +72,49 @@ export const UCAN_STORE_DEFAULT_URLS: Record<string, string> = {
65
72
  };
66
73
 
67
74
  /**
68
- * The user has not deposited an `ixo:filesystem` delegation for this oracle
69
- * in the UCAN store (yet). Distinguished from transient store/VFS failures so
70
- * the migrating store can treat "not on VFS yet" as *no VFS copy* (and read
71
- * the legacy Matrix media) instead of failing the whole boot — while genuine
72
- * outages still fail loudly rather than silently serving a stale legacy copy.
75
+ * The user's delegation to this oracle (if any) grants no `ixo:filesystem`
76
+ * capability covering `/.oracles`. Distinguished from transient VFS failures
77
+ * so the migrating store can treat "not on VFS yet" as *no VFS copy* (and
78
+ * read the legacy Matrix media) instead of failing the whole boot — while
79
+ * genuine outages still fail loudly rather than silently serving a stale
80
+ * legacy copy.
73
81
  */
74
82
  export class VfsNoDelegationError extends Error {
75
- constructor(userDid: string) {
83
+ constructor(userDid: string, reason: VfsNoDelegationReason) {
76
84
  super(
77
- `No active ixo:filesystem delegation from ${userDid} in the UCAN store`,
85
+ reason === 'no-delegation'
86
+ ? `${userDid} has not deposited a delegation for this oracle (POST /delegation)`
87
+ : `${userDid}'s delegation to this oracle carries no ixo:filesystem capability covering ${ORACLES_ROOT}`,
78
88
  );
79
89
  this.name = 'VfsNoDelegationError';
80
90
  }
81
91
  }
92
+ export type VfsNoDelegationReason = 'no-delegation' | 'no-capability';
82
93
 
83
94
  /**
84
- * The UCAN store (which holds the user's delegation) was unreachable or
85
- * failing — transient, unlike a missing delegation. Retried like a 5xx.
95
+ * The capability of a user's delegation that lets this store reach the
96
+ * `/.oracles` folder with `ability`, or null. A qualifying `with` is the
97
+ * whole personal filesystem or exactly its `/.oracles` subtree — a narrower
98
+ * scope (say `/.oracles/<oracleDid>`) cannot list `/.oracles`, which the
99
+ * store must do because a full path with the DID embedded is too long for
100
+ * the VFS's filter (see `listOracleFiles()`). Domain namespaces
101
+ * (`ixo:filesystem/did:…`) never qualify.
86
102
  */
87
- export class VfsStoreUnavailableError extends Error {
88
- readonly retryable = true as const;
89
-
90
- constructor(detail: string) {
91
- super(`UCAN store unavailable while resolving VFS access: ${detail}`);
92
- this.name = 'VfsStoreUnavailableError';
93
- }
103
+ export function ownerCopyCapability<C extends { can: string; with: string }>(
104
+ capabilities: readonly C[],
105
+ ability: string,
106
+ ): C | null {
107
+ const qualifyingScope = (resource: string): boolean => {
108
+ if (resource === VFS_RESOURCE) return true;
109
+ if (!resource.startsWith(`${VFS_RESOURCE}/`)) return false;
110
+ const scope = resource.slice(VFS_RESOURCE.length).replace(/\/+$/, '');
111
+ return scope === '' || scope === ORACLES_ROOT;
112
+ };
113
+ return (
114
+ capabilities.find(
115
+ (c) => qualifyingScope(c.with) && abilityCovers(c.can, ability),
116
+ ) ?? null
117
+ );
94
118
  }
95
119
 
96
120
  /** A non-2xx VFS response, with the status and body for callers that recover. */
@@ -134,7 +158,6 @@ function describe(error: unknown): string {
134
158
  export function isRetryableRequest(error: unknown): boolean {
135
159
  if (error instanceof VfsRequestError)
136
160
  return error.status >= 500 || error.status === 429;
137
- if (error instanceof VfsStoreUnavailableError) return true;
138
161
  return isNetworkError(error);
139
162
  }
140
163
 
@@ -144,7 +167,12 @@ export interface IxoVfsOwnerStoreOptions {
144
167
  oracleDid: string;
145
168
  /** VFS worker origin, e.g. `https://devnet.vfs.ixo.earth`. */
146
169
  vfsBaseUrl: string;
147
- ucanStoreUrl: string;
170
+ /**
171
+ * The user's current delegation to this oracle (serialized CAR), or
172
+ * undefined when none is deposited. Read on every request so a delegation
173
+ * deposited or revoked mid-life takes effect at once.
174
+ */
175
+ delegation: () => string | undefined;
148
176
  fetchImpl?: typeof fetch;
149
177
  timeoutMs?: number;
150
178
  /** Backoff between retries of a failed request (tests: zeros). */
@@ -468,30 +496,23 @@ export class IxoVfsOwnerStore implements OwnerStore {
468
496
  private async bearer(
469
497
  ability: 'fs/list' | 'fs/read' | 'fs/write' | 'fs/delete',
470
498
  ): Promise<string> {
471
- const delegation = await this.opts.ucan.getServiceDelegation(
472
- this.opts.userDid,
473
- {
474
- storeUrl: this.opts.ucanStoreUrl,
475
- resource: VFS_RESOURCE,
476
- requiredAbility: ability,
477
- },
499
+ const raw = this.opts.delegation();
500
+ if (!raw)
501
+ throw new VfsNoDelegationError(this.opts.userDid, 'no-delegation');
502
+ const capability = ownerCopyCapability(
503
+ await this.opts.ucan.delegationCapabilities(raw),
504
+ ability,
478
505
  );
479
- if ('error' in delegation) {
480
- if (delegation.error === 'no-delegation')
481
- throw new VfsNoDelegationError(this.opts.userDid);
482
- // 'store-error': the UCAN store itself failed (5xx, network) — transient.
483
- throw new VfsStoreUnavailableError(
484
- `${delegation.error}${delegation.detail ? ` (${delegation.detail})` : ''}`,
485
- );
486
- }
506
+ if (!capability)
507
+ throw new VfsNoDelegationError(this.opts.userDid, 'no-capability');
487
508
  const minted = await this.opts.ucan.createInvocationFromDelegation(
488
- delegation.token,
509
+ raw,
489
510
  this.opts.vfsBaseUrl,
490
511
  // `nb.hidden: ['*']` imposes NO widening — the VFS intersects reveal
491
512
  // sets across the chain, so the effective reveal is exactly what the
492
513
  // user's delegation granted. Omitting it would zero the reveal and make
493
514
  // the dot-folder state file invisible to its own writer.
494
- { can: ability, with: delegation.with, nb: { hidden: ['*'] } },
515
+ { can: ability, with: capability.with, nb: { hidden: ['*'] } },
495
516
  { maxTtlSeconds: INVOCATION_TTL_SECONDS },
496
517
  );
497
518
  if ('error' in minted)
@@ -3,22 +3,19 @@
3
3
  * the object retries only what can succeed on its own and the shell can
4
4
  * answer with an honest status code:
5
5
  *
6
- * - `NO_VFS_DELEGATION` (403, not retryable): the user has not deposited an
7
- * `ixo:filesystem` delegation for this oracle. Retrying cannot help; the
8
- * client must deposit the grant and try again.
6
+ * - `NO_VFS_DELEGATION` (403, not retryable): the user's delegation to this
7
+ * oracle (or the lack of one) grants no `ixo:filesystem` capability over
8
+ * `/.oracles`. Retrying cannot help; the client must authorize the oracle
9
+ * again with file storage included and try again.
9
10
  * - `VFS_AUTH_FAILED` (403, not retryable): the VFS rejected the oracle's
10
11
  * credentials (401/403). Something is misconfigured; retrying cannot help.
11
- * - `OWNER_COPY_UNAVAILABLE` (503, retryable): the UCAN store or the VFS was
12
- * unreachable or failing (network, 5xx, 429, timeout). The object already
13
- * retried with backoff; the next request tries again from scratch.
12
+ * - `OWNER_COPY_UNAVAILABLE` (503, retryable): the VFS was unreachable or
13
+ * failing (network, 5xx, 429, timeout). The object already retried with
14
+ * backoff; the next request tries again from scratch.
14
15
  *
15
16
  * In every case nothing was written — the boot is not memoised on failure.
16
17
  */
17
- import {
18
- VfsNoDelegationError,
19
- VfsRequestError,
20
- VfsStoreUnavailableError,
21
- } from './ixo-vfs-store';
18
+ import { VfsNoDelegationError, VfsRequestError } from './ixo-vfs-store';
22
19
 
23
20
  export type OwnerCopyErrorCode =
24
21
  | 'NO_VFS_DELEGATION'
@@ -73,7 +70,7 @@ function messageFor(
73
70
  ): string {
74
71
  switch (code) {
75
72
  case 'NO_VFS_DELEGATION':
76
- return `Your oracle data cannot be loaded because this oracle has no file-storage permission from you yet. Deposit an ixo:filesystem delegation for it, then try again. Nothing was changed. (${detail})`;
73
+ return `Your oracle data cannot be loaded because your authorization for this oracle does not include file storage (ixo:filesystem over /.oracles). Authorize the oracle again to grant it, then try again. Nothing was changed. (${detail})`;
77
74
  case 'VFS_AUTH_FAILED':
78
75
  return `Your oracle data cannot be loaded because your file storage rejected the oracle's credentials. Nothing was changed. (${detail})`;
79
76
  default:
@@ -81,9 +78,6 @@ function messageFor(
81
78
  }
82
79
  }
83
80
 
84
- // Re-exported so the shell can map the transient store failure too.
85
- export { VfsStoreUnavailableError };
86
-
87
81
  /** What the shell needs to answer a request whose owner copy could not load. */
88
82
  export interface OwnerCopyFailure {
89
83
  code: OwnerCopyErrorCode;
@@ -163,13 +157,5 @@ export function parseOwnerCopyFailure(error: unknown): OwnerCopyFailure | null {
163
157
  message: error.message,
164
158
  };
165
159
  }
166
- if (error.name === 'VfsStoreUnavailableError') {
167
- return {
168
- code: 'OWNER_COPY_UNAVAILABLE',
169
- httpStatus: 503,
170
- retryable: true,
171
- message: error.message,
172
- };
173
- }
174
160
  return null;
175
161
  }
@@ -97,6 +97,14 @@ const SERVER_EVENTS = [
97
97
  'message_cache_invalidation',
98
98
  ] as const;
99
99
 
100
+ /** Socket event every runtime event travels under, carrying `{ eventName, payload }` (Node's wire). */
101
+ const ENVELOPE_EVENT = 'event';
102
+ /** Frontend calls the client SDK listens for by name with the raw payload. */
103
+ const RAW_NAMED_EVENTS: ReadonlySet<string> = new Set([
104
+ 'browser_tool_call',
105
+ 'action_call',
106
+ ]);
107
+
100
108
  /** Close codes in the 4000–4999 application range. */
101
109
  const CLOSE_UNAUTHORIZED = 4401;
102
110
  const CLOSE_HANDSHAKE_TIMEOUT = 4408;
@@ -547,11 +555,25 @@ export class RealtimeEndpoint {
547
555
 
548
556
  // ── outbound ────────────────────────────────────────────────────────────
549
557
 
558
+ /**
559
+ * Mirror a runtime event to the sockets of the session it belongs to, on
560
+ * the Node runtime's wire: the socket event is `event` and its argument is
561
+ * the `@ixo/oracles-events` envelope `{ eventName, payload }`. The client
562
+ * SDK's catch-all listener validates exactly that (`payload.sessionId` and
563
+ * `payload.requestId`) and drops a bare payload, while its named
564
+ * `tool_call` / `render_component` listeners crash on one. The two calls
565
+ * the SDK answers (`browser_tool_call`, `action_call`) are the exception:
566
+ * it listens for them by name with the raw payload, on both runtimes.
567
+ */
550
568
  private fanOut(eventName: string, payload: RawEventPayload): void {
551
569
  const sessionId =
552
570
  typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
553
571
  if (!sessionId) return;
554
- this.hub.emitToSession(sessionId, eventName, payload);
572
+ if (RAW_NAMED_EVENTS.has(eventName)) {
573
+ this.hub.emitToSession(sessionId, eventName, payload);
574
+ return;
575
+ }
576
+ this.hub.emitToSession(sessionId, ENVELOPE_EVENT, { eventName, payload });
555
577
  }
556
578
 
557
579
  private call(
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Where the Node runtime keeps the oracle's UCAN signing mnemonic, and how
3
+ * to read it (`oracles-chain-client`'s `setup-claim-signing-mnemonics.ts`):
4
+ * the account room's state event `ixo.room.state.secure` /
5
+ * `encrypted_mnemonic_ed_signing` with content
6
+ * `{ encrypted_mnemonic: 'ivHex:cipherHex' }`, AES-256-CBC under
7
+ * `MATRIX_VALUE_PIN` (`decryptWithPin`). The Workers runtime only reads it —
8
+ * provisioning stays with the Node runtime and the CLI.
9
+ */
10
+
11
+ export const SIGNING_MNEMONIC_STATE_TYPE = 'ixo.room.state.secure';
12
+ export const SIGNING_MNEMONIC_STATE_KEY = 'encrypted_mnemonic_ed_signing';
13
+
14
+ /** The `encrypted_mnemonic` of a state-event content JSON, or null. */
15
+ export function encryptedMnemonicOf(contentJson: string): string | null {
16
+ let content: unknown;
17
+ try {
18
+ content = JSON.parse(contentJson);
19
+ } catch {
20
+ return null;
21
+ }
22
+ if (typeof content !== 'object' || content === null) return null;
23
+ const value = (content as { encrypted_mnemonic?: unknown })
24
+ .encrypted_mnemonic;
25
+ return typeof value === 'string' && /^[0-9a-f]+:[0-9a-f]+$/i.test(value)
26
+ ? value
27
+ : null;
28
+ }
29
+
30
+ const WORD_COUNTS = new Set([12, 15, 18, 21, 24]);
31
+
32
+ /**
33
+ * A decrypted value that looks like a BIP-39 mnemonic (12–24 lowercase
34
+ * words), normalised to single spaces — or null. A wrong PIN decrypts to
35
+ * bytes that never pass this, which keeps garbage out of the signer.
36
+ */
37
+ export function parseSigningMnemonic(plain: string): string | null {
38
+ const words = plain.trim().split(/\s+/);
39
+ if (!WORD_COUNTS.has(words.length)) return null;
40
+ if (!words.every((w) => /^[a-z]+$/.test(w))) return null;
41
+ return words.join(' ');
42
+ }
package/src/shell/app.ts CHANGED
@@ -16,6 +16,10 @@ import {
16
16
  decodeRoomStateContent,
17
17
  encodeRoomStateContent,
18
18
  } from '../matrix/room-state-codec';
19
+ import {
20
+ listDelegationCapabilities,
21
+ type DelegatedCapability,
22
+ } from '../do/ucan-service';
19
23
  import {
20
24
  authenticate,
21
25
  isExcluded,
@@ -358,9 +362,23 @@ export function createShell(
358
362
  const authorized =
359
363
  Boolean(state?.raw) &&
360
364
  (exp === undefined || exp > Math.floor(Date.now() / 1000));
365
+ // The stored delegation's capabilities, so a client can tell an older
366
+ // delegation (minted before a capability existed — e.g. the file-storage
367
+ // grant the owner copy needs) from a current one and re-authorize.
368
+ let capabilities: DelegatedCapability[] | undefined;
369
+ if (authorized && state?.raw) {
370
+ try {
371
+ capabilities = await listDelegationCapabilities(state.raw);
372
+ } catch (err) {
373
+ console.warn(
374
+ `[shell] stored delegation for ${auth.userDid} could not be parsed: ${err instanceof Error ? err.message : String(err)}`,
375
+ );
376
+ }
377
+ }
361
378
  return c.json({
362
379
  authorized,
363
380
  ...(exp !== undefined && { expiration: exp }),
381
+ ...(capabilities !== undefined && { capabilities }),
364
382
  });
365
383
  });
366
384
  app.delete('/delegation', async (c) => {