@ixo/oracle-runtime-workers 0.2.0 → 0.3.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 +6 -1
- package/package.json +12 -12
- package/src/do/ucan-service.ts +45 -1
- package/src/do/user-oracle-do.ts +30 -5
- package/src/index.ts +16 -1
- package/src/owner-store/ixo-vfs-store.ts +64 -43
- package/src/owner-store/owner-copy-errors.ts +9 -23
- package/src/realtime/realtime-endpoint.ts +23 -1
- package/src/shell/app.ts +18 -0
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
+
}
|
package/src/do/ucan-service.ts
CHANGED
|
@@ -36,7 +36,8 @@ function toSupportedDid(did: string): SupportedDID {
|
|
|
36
36
|
throw new Error(`Unsupported oracle DID method: ${did}`);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
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;
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -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,25 @@ 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 object holds a delegation it had not seen before (a header on this
|
|
571
|
+
* request, or a deposit through the shell). A fresh delegation is the one
|
|
572
|
+
* thing that turns a "no file-storage grant" flush failure into a
|
|
573
|
+
* success: forget the failure streak and, with unsaved turns, flush now
|
|
574
|
+
* instead of at the next 10-minute retry.
|
|
575
|
+
*/
|
|
576
|
+
private async onDelegationReplaced(): Promise<void> {
|
|
577
|
+
await this.ctx.storage.delete(META_FLUSH_FAILURES);
|
|
578
|
+
if (!this.db) return;
|
|
579
|
+
if (
|
|
580
|
+
!this.dirty &&
|
|
581
|
+
(await this.ctx.storage.get<boolean>(META_DIRTY)) === true
|
|
582
|
+
)
|
|
583
|
+
this.dirty = true;
|
|
584
|
+
if (this.dirty) void this.flushToOwnerStore().catch(() => undefined);
|
|
560
585
|
}
|
|
561
586
|
|
|
562
587
|
/**
|
|
@@ -1003,10 +1028,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1003
1028
|
env.VFS_BASE_URL ??
|
|
1004
1029
|
VFS_DEFAULT_BASE_URLS[network] ??
|
|
1005
1030
|
VFS_DEFAULT_BASE_URLS.devnet!,
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
UCAN_STORE_DEFAULT_URLS.devnet!,
|
|
1031
|
+
// The user's one delegation to this oracle — hydrated by `ready()`
|
|
1032
|
+
// before boot, replaced at once by `setDelegation` / `clearDelegation`.
|
|
1033
|
+
delegation: () => this.delegations.get(userDid)?.raw,
|
|
1010
1034
|
});
|
|
1011
1035
|
return new MigratingOwnerStore({
|
|
1012
1036
|
primary: vfsStore,
|
|
@@ -1506,6 +1530,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1506
1530
|
at: Date.now(),
|
|
1507
1531
|
...(typeof expiration === 'number' ? { expiration } : {}),
|
|
1508
1532
|
} satisfies StoredDelegation);
|
|
1533
|
+
await this.onDelegationReplaced();
|
|
1509
1534
|
}
|
|
1510
1535
|
|
|
1511
1536
|
/**
|
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 {
|
|
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
|
});
|
|
@@ -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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
69
|
-
*
|
|
70
|
-
* the migrating store can treat "not on VFS yet" as *no VFS copy* (and
|
|
71
|
-
* the legacy Matrix media) instead of failing the whole boot — while
|
|
72
|
-
* outages still fail loudly rather than silently serving a stale
|
|
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
|
-
|
|
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
|
|
85
|
-
*
|
|
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
|
|
88
|
-
readonly
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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 (
|
|
480
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
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) => {
|