@ixo/oracle-runtime-workers 0.15.0 → 0.16.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 +1 -1
- package/package.json +3 -3
- package/src/channels/auth.ts +54 -11
- package/src/channels/contract.ts +122 -13
- package/src/channels/turns.ts +12 -2
- package/src/do/user-oracle-do.ts +128 -18
- package/src/shell/app.ts +41 -8
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ Every variable, binding and migration is explained in
|
|
|
91
91
|
| `POST/GET /sessions`, `POST/GET /messages/:id` | The chat API |
|
|
92
92
|
| `GET /runs/:runId`, `GET /sessions/:id/messages` | Re-join a durable run; one page of a transcript |
|
|
93
93
|
| `/delegation`, `/models`, `/socket.io/`, `/byo-llm/*` | Delegation, models, realtime channel, BYO LLM |
|
|
94
|
-
| `POST /channels/turn` | IXO Channels ingress
|
|
94
|
+
| `POST /channels/turn` | IXO Channels ingress; refusals carry a `code` |
|
|
95
95
|
| `POST /messages/:sessionId/:messageId/feedback` | Anonymous feedback on a reply (when configured) |
|
|
96
96
|
| `/topic-deliverables/:operationId` | Owner-only Topic deliverables (when enabled) |
|
|
97
97
|
| `GET /a/:id`, `GET /a/:id/data` | Artefact links opened from chat apps (public) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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": {
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"yjs": "13.6.32",
|
|
58
58
|
"zod": "^4.4.3",
|
|
59
59
|
"@ixo/common": "1.6.0",
|
|
60
|
-
"@ixo/
|
|
61
|
-
"@ixo/
|
|
60
|
+
"@ixo/ucan": "2.2.1",
|
|
61
|
+
"@ixo/ixo-transaction": "0.1.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
package/src/channels/auth.ts
CHANGED
|
@@ -58,7 +58,11 @@ export async function authenticateChannel(
|
|
|
58
58
|
token.length > 32_000 ||
|
|
59
59
|
headers.get('x-auth-type')?.toLowerCase() !== 'ucan'
|
|
60
60
|
)
|
|
61
|
-
throw new ChannelError(
|
|
61
|
+
throw new ChannelError(
|
|
62
|
+
401,
|
|
63
|
+
'A channel UCAN invocation is required',
|
|
64
|
+
'unauthorized',
|
|
65
|
+
);
|
|
62
66
|
const validator = await createUCANValidator({
|
|
63
67
|
serverDid: config.oracleDid,
|
|
64
68
|
rootIssuers: ['*'],
|
|
@@ -88,7 +92,11 @@ export async function authenticateChannel(
|
|
|
88
92
|
chain[1] !== config.channelDid ||
|
|
89
93
|
result.invoker !== config.channelDid
|
|
90
94
|
)
|
|
91
|
-
throw new ChannelError(
|
|
95
|
+
throw new ChannelError(
|
|
96
|
+
401,
|
|
97
|
+
'Invalid user-rooted channel invocation',
|
|
98
|
+
'unauthorized',
|
|
99
|
+
);
|
|
92
100
|
const now = Math.floor(Date.now() / 1000);
|
|
93
101
|
const invocation = await parseDelegation(token);
|
|
94
102
|
const proof = invocation.proofs[0];
|
|
@@ -109,6 +117,7 @@ export async function authenticateChannel(
|
|
|
109
117
|
throw new ChannelError(
|
|
110
118
|
401,
|
|
111
119
|
'A bounded, direct user channel grant is required',
|
|
120
|
+
'unauthorized',
|
|
112
121
|
);
|
|
113
122
|
if (
|
|
114
123
|
!result.expiration ||
|
|
@@ -121,6 +130,7 @@ export async function authenticateChannel(
|
|
|
121
130
|
throw new ChannelError(
|
|
122
131
|
401,
|
|
123
132
|
'Channel invocations must expire within 60 seconds',
|
|
133
|
+
'unauthorized',
|
|
124
134
|
);
|
|
125
135
|
const nb = result.capability?.nb;
|
|
126
136
|
if (
|
|
@@ -130,7 +140,11 @@ export async function authenticateChannel(
|
|
|
130
140
|
nb.bindingRevision !== input.bindingRevision ||
|
|
131
141
|
nb.oracleDid !== config.oracleDid
|
|
132
142
|
)
|
|
133
|
-
throw new ChannelError(
|
|
143
|
+
throw new ChannelError(
|
|
144
|
+
403,
|
|
145
|
+
'Channel scope does not match this request',
|
|
146
|
+
'scope_mismatch',
|
|
147
|
+
);
|
|
134
148
|
const hash = await channelRequestHash(raw);
|
|
135
149
|
if (
|
|
136
150
|
result.facts?.length !== 1 ||
|
|
@@ -140,6 +154,7 @@ export async function authenticateChannel(
|
|
|
140
154
|
throw new ChannelError(
|
|
141
155
|
403,
|
|
142
156
|
'Invocation does not authorize this request body',
|
|
157
|
+
'scope_mismatch',
|
|
143
158
|
);
|
|
144
159
|
return {
|
|
145
160
|
userDid,
|
|
@@ -168,7 +183,11 @@ export function channelAuthConfig(
|
|
|
168
183
|
>,
|
|
169
184
|
): ChannelAuthConfig {
|
|
170
185
|
if (!env.CHANNEL_SERVICE_DID)
|
|
171
|
-
throw new ChannelError(
|
|
186
|
+
throw new ChannelError(
|
|
187
|
+
503,
|
|
188
|
+
'Channels are not configured',
|
|
189
|
+
'not_configured',
|
|
190
|
+
);
|
|
172
191
|
const resolver = createCompositeDIDResolver([
|
|
173
192
|
sharedIxoDIDResolver(env.BLOCKSYNC_GRAPHQL_URL),
|
|
174
193
|
channelWebResolver,
|
|
@@ -204,15 +223,23 @@ export async function assertActiveChannelBinding(
|
|
|
204
223
|
>,
|
|
205
224
|
): Promise<void> {
|
|
206
225
|
if (!identity.channel)
|
|
207
|
-
throw new ChannelError(
|
|
226
|
+
throw new ChannelError(
|
|
227
|
+
403,
|
|
228
|
+
'Channel identity is required',
|
|
229
|
+
'identity_required',
|
|
230
|
+
);
|
|
208
231
|
if (!env.AUTH_HUB_CHANNEL_SERVICE_KEY || (!env.AUTH_HUB && !env.AUTH_HUB_URL))
|
|
209
|
-
throw new ChannelError(
|
|
232
|
+
throw new ChannelError(
|
|
233
|
+
503,
|
|
234
|
+
'Channel binding validation is not configured',
|
|
235
|
+
'not_configured',
|
|
236
|
+
);
|
|
210
237
|
const url = new URL(
|
|
211
238
|
'/api/internal/channels/validate-binding',
|
|
212
239
|
env.AUTH_HUB_URL ?? 'https://auth-hub.internal',
|
|
213
240
|
);
|
|
214
241
|
if (url.protocol !== 'https:')
|
|
215
|
-
throw new ChannelError(503, 'Auth Hub requires HTTPS');
|
|
242
|
+
throw new ChannelError(503, 'Auth Hub requires HTTPS', 'not_configured');
|
|
216
243
|
const request = new Request(url, {
|
|
217
244
|
method: 'POST',
|
|
218
245
|
headers: {
|
|
@@ -235,15 +262,31 @@ export async function assertActiveChannelBinding(
|
|
|
235
262
|
? env.AUTH_HUB.fetch(request)
|
|
236
263
|
: fetch(request));
|
|
237
264
|
} catch {
|
|
238
|
-
throw new ChannelError(
|
|
265
|
+
throw new ChannelError(
|
|
266
|
+
503,
|
|
267
|
+
'Channel binding validation is unavailable',
|
|
268
|
+
'binding_check_unavailable',
|
|
269
|
+
);
|
|
239
270
|
}
|
|
240
271
|
if (!response.ok)
|
|
241
|
-
throw new ChannelError(
|
|
272
|
+
throw new ChannelError(
|
|
273
|
+
503,
|
|
274
|
+
'Channel binding validation is unavailable',
|
|
275
|
+
'binding_check_unavailable',
|
|
276
|
+
);
|
|
242
277
|
const parsed = BindingVerdict.safeParse(await response.json());
|
|
243
278
|
if (!parsed.success)
|
|
244
|
-
throw new ChannelError(
|
|
279
|
+
throw new ChannelError(
|
|
280
|
+
503,
|
|
281
|
+
'Invalid channel binding validation response',
|
|
282
|
+
'binding_check_unavailable',
|
|
283
|
+
);
|
|
245
284
|
if (!parsed.data.active)
|
|
246
|
-
throw new ChannelError(
|
|
285
|
+
throw new ChannelError(
|
|
286
|
+
403,
|
|
287
|
+
'Channel binding is inactive',
|
|
288
|
+
'binding_inactive',
|
|
289
|
+
);
|
|
247
290
|
}
|
|
248
291
|
|
|
249
292
|
/**
|
package/src/channels/contract.ts
CHANGED
|
@@ -66,34 +66,130 @@ export interface ChannelTurnResponse {
|
|
|
66
66
|
plan?: ReplyPlan;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export type ChannelErrorStatus =
|
|
70
|
+
| 400
|
|
71
|
+
| 401
|
|
72
|
+
| 403
|
|
73
|
+
| 404
|
|
74
|
+
| 409
|
|
75
|
+
| 410
|
|
76
|
+
| 413
|
|
77
|
+
| 428
|
|
78
|
+
| 429
|
|
79
|
+
| 503;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The machine-readable reason a channel turn was refused. The gateway maps
|
|
83
|
+
* a refusal by `code` first and by HTTP status second. 428 (Precondition
|
|
84
|
+
* 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
|
|
86
|
+
* retry; it is never a transient outage.
|
|
87
|
+
*
|
|
88
|
+
* | code | status | meaning |
|
|
89
|
+
* | --------------------------------- | ------ | ----------------------------------------------------- |
|
|
90
|
+
* | `invalid_request` | 400 | body missing, not UTF-8, not JSON, or the wrong shape |
|
|
91
|
+
* | `unauthorized` | 401 | no valid user-rooted channel invocation |
|
|
92
|
+
* | `scope_mismatch` | 403 | the invocation does not cover this binding or body |
|
|
93
|
+
* | `identity_mismatch` | 403 | the channel identity does not match the request |
|
|
94
|
+
* | `identity_required` | 403 | the binding check got no channel identity |
|
|
95
|
+
* | `binding_inactive` | 403 | the Auth Hub reports the binding inactive |
|
|
96
|
+
* | `channel_grant_not_tool_authority`| 403 | a channel request carried a delegation header |
|
|
97
|
+
* | `session_not_found` | 404 | the session is not the user's Companion session |
|
|
98
|
+
* | `request_conflict` | 409 | the request id already belongs to another message |
|
|
99
|
+
* | `session_conflict` | 409 | the channel is already bound to another session |
|
|
100
|
+
* | `room_not_encrypted` | 409 | the Companion room is not end-to-end encrypted |
|
|
101
|
+
* | `response_expired` | 410 | the run was pruned; the request cannot run again |
|
|
102
|
+
* | `request_too_large` | 413 | the body is over 64 kB |
|
|
103
|
+
* | `room_not_ready` | 428 | the user has no Companion room yet |
|
|
104
|
+
* | `delegation_required` | 428 | no usable delegation to this oracle (see below) |
|
|
105
|
+
* | `rate_limited` | 429 | too many requests for this user |
|
|
106
|
+
* | `not_configured` | 503 | channels or binding validation are not configured |
|
|
107
|
+
* | `binding_check_unavailable` | 503 | the Auth Hub binding check failed or answered badly |
|
|
108
|
+
* | `unavailable` | 503 | any other failure (the shell logs it) |
|
|
109
|
+
*/
|
|
110
|
+
export type ChannelErrorCode =
|
|
111
|
+
| 'invalid_request'
|
|
112
|
+
| 'unauthorized'
|
|
113
|
+
| 'scope_mismatch'
|
|
114
|
+
| 'identity_mismatch'
|
|
115
|
+
| 'identity_required'
|
|
116
|
+
| 'binding_inactive'
|
|
117
|
+
| 'channel_grant_not_tool_authority'
|
|
118
|
+
| 'session_not_found'
|
|
119
|
+
| 'request_conflict'
|
|
120
|
+
| 'session_conflict'
|
|
121
|
+
| 'room_not_encrypted'
|
|
122
|
+
| 'response_expired'
|
|
123
|
+
| 'request_too_large'
|
|
124
|
+
| 'room_not_ready'
|
|
125
|
+
| 'delegation_required'
|
|
126
|
+
| 'rate_limited'
|
|
127
|
+
| 'not_configured'
|
|
128
|
+
| 'binding_check_unavailable'
|
|
129
|
+
| 'unavailable';
|
|
130
|
+
|
|
69
131
|
export type ChannelTurnOutcome =
|
|
70
132
|
| { ok: true; result: ChannelTurnResponse }
|
|
71
133
|
| {
|
|
72
134
|
ok: false;
|
|
73
|
-
status:
|
|
135
|
+
status: ChannelErrorStatus;
|
|
136
|
+
code: ChannelErrorCode;
|
|
74
137
|
message: string;
|
|
75
138
|
};
|
|
76
139
|
|
|
77
140
|
export class ChannelError extends Error {
|
|
78
141
|
constructor(
|
|
79
|
-
readonly status:
|
|
142
|
+
readonly status: ChannelErrorStatus,
|
|
80
143
|
message: string,
|
|
144
|
+
readonly code: ChannelErrorCode,
|
|
81
145
|
) {
|
|
82
146
|
super(message);
|
|
147
|
+
this.name = 'ChannelError';
|
|
83
148
|
}
|
|
84
149
|
}
|
|
85
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The least lifetime a delegation must have left for a channel turn to be
|
|
153
|
+
* admitted (seconds): a turn, its tool calls and its owner-copy flush must
|
|
154
|
+
* not outlive the delegation they run under.
|
|
155
|
+
*/
|
|
156
|
+
export const CHANNEL_DELEGATION_MIN_REMAINING_SECONDS = 900;
|
|
157
|
+
|
|
158
|
+
/** The user's delegation to this oracle as the user object holds it. */
|
|
159
|
+
export interface ChannelDelegation {
|
|
160
|
+
raw?: string;
|
|
161
|
+
/** Unix seconds: the delegation's effective expiry; absent = unknown. */
|
|
162
|
+
expiration?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function delegationRequiredError(): ChannelError {
|
|
166
|
+
return new ChannelError(
|
|
167
|
+
428,
|
|
168
|
+
'Companion delegation required: the user must authorize this oracle again',
|
|
169
|
+
'delegation_required',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
86
173
|
/**
|
|
87
174
|
* A channel turn runs under the user's stored delegation to this oracle,
|
|
88
|
-
* like a Matrix turn. Without one
|
|
89
|
-
*
|
|
175
|
+
* like a Matrix turn. Without one, with an unknown expiry, or with less than
|
|
176
|
+
* `CHANNEL_DELEGATION_MIN_REMAINING_SECONDS` left, there is no tool
|
|
177
|
+
* authority to run under: the turn is refused with an explicit 428
|
|
178
|
+
* `delegation_required` instead of running degraded (memory, files and the
|
|
179
|
+
* sandbox would fail silently).
|
|
90
180
|
*/
|
|
91
|
-
export function requireChannelDelegation(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
181
|
+
export function requireChannelDelegation(
|
|
182
|
+
delegation: ChannelDelegation | undefined,
|
|
183
|
+
nowSeconds: number,
|
|
184
|
+
): void {
|
|
185
|
+
if (
|
|
186
|
+
!delegation?.raw ||
|
|
187
|
+
typeof delegation.expiration !== 'number' ||
|
|
188
|
+
!Number.isFinite(delegation.expiration) ||
|
|
189
|
+
delegation.expiration <=
|
|
190
|
+
nowSeconds + CHANNEL_DELEGATION_MIN_REMAINING_SECONDS
|
|
191
|
+
)
|
|
192
|
+
throw delegationRequiredError();
|
|
97
193
|
}
|
|
98
194
|
|
|
99
195
|
export async function channelRequestHash(raw: string): Promise<string> {
|
|
@@ -108,7 +204,12 @@ export async function channelRequestHash(raw: string): Promise<string> {
|
|
|
108
204
|
|
|
109
205
|
export async function readChannelBody(request: Request): Promise<string> {
|
|
110
206
|
const reader = request.body?.getReader();
|
|
111
|
-
if (!reader)
|
|
207
|
+
if (!reader)
|
|
208
|
+
throw new ChannelError(
|
|
209
|
+
400,
|
|
210
|
+
'A channel turn is required',
|
|
211
|
+
'invalid_request',
|
|
212
|
+
);
|
|
112
213
|
const chunks: Uint8Array[] = [];
|
|
113
214
|
let size = 0;
|
|
114
215
|
while (true) {
|
|
@@ -117,7 +218,11 @@ export async function readChannelBody(request: Request): Promise<string> {
|
|
|
117
218
|
size += chunk.value.byteLength;
|
|
118
219
|
if (size > 64_000) {
|
|
119
220
|
await reader.cancel();
|
|
120
|
-
throw new ChannelError(
|
|
221
|
+
throw new ChannelError(
|
|
222
|
+
413,
|
|
223
|
+
'Channel request is too large',
|
|
224
|
+
'request_too_large',
|
|
225
|
+
);
|
|
121
226
|
}
|
|
122
227
|
chunks.push(chunk.value);
|
|
123
228
|
}
|
|
@@ -132,6 +237,10 @@ export async function readChannelBody(request: Request): Promise<string> {
|
|
|
132
237
|
bytes,
|
|
133
238
|
);
|
|
134
239
|
} catch {
|
|
135
|
-
throw new ChannelError(
|
|
240
|
+
throw new ChannelError(
|
|
241
|
+
400,
|
|
242
|
+
'Channel request must be UTF-8',
|
|
243
|
+
'invalid_request',
|
|
244
|
+
);
|
|
136
245
|
}
|
|
137
246
|
}
|
package/src/channels/turns.ts
CHANGED
|
@@ -23,7 +23,10 @@ interface ReceiptRow extends Record<string, string | number | null> {
|
|
|
23
23
|
export interface ChannelTurnsHost {
|
|
24
24
|
createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
|
|
25
25
|
assertSession(identity: TurnIdentity, sessionId: string): Promise<void>;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Throws a `ChannelError` (428 `delegation_required`) when the user has no
|
|
28
|
+
* usable delegation for this oracle: none, or one too close to expiry.
|
|
29
|
+
*/
|
|
27
30
|
requireDelegation(identity: TurnIdentity): Promise<void>;
|
|
28
31
|
getRun(runId: string): Promise<RunRecord | undefined>;
|
|
29
32
|
/** The Reply Plan (JSON) a finished run was built into, if any. */
|
|
@@ -167,6 +170,7 @@ export class ChannelTurns {
|
|
|
167
170
|
throw new ChannelError(
|
|
168
171
|
403,
|
|
169
172
|
'Channel identity does not match this request',
|
|
173
|
+
'identity_mismatch',
|
|
170
174
|
);
|
|
171
175
|
const runId = `channel_${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`;
|
|
172
176
|
// Checked before a receipt is written: the receipt of a pruned run is
|
|
@@ -175,6 +179,7 @@ export class ChannelTurns {
|
|
|
175
179
|
throw new ChannelError(
|
|
176
180
|
410,
|
|
177
181
|
'Channel response has expired; this request cannot execute again',
|
|
182
|
+
'response_expired',
|
|
178
183
|
);
|
|
179
184
|
const admitted = await this.db.run(
|
|
180
185
|
`INSERT OR IGNORE INTO channel_requests
|
|
@@ -191,6 +196,7 @@ export class ChannelTurns {
|
|
|
191
196
|
throw new ChannelError(
|
|
192
197
|
409,
|
|
193
198
|
'This request ID already belongs to another message',
|
|
199
|
+
'request_conflict',
|
|
194
200
|
);
|
|
195
201
|
let sessionId = receipt.session_id;
|
|
196
202
|
// The session is checked when this request is bound to it, and again
|
|
@@ -203,7 +209,11 @@ export class ChannelTurns {
|
|
|
203
209
|
[input.bindingId],
|
|
204
210
|
);
|
|
205
211
|
if (binding && input.sessionId && binding.session_id !== input.sessionId)
|
|
206
|
-
throw new ChannelError(
|
|
212
|
+
throw new ChannelError(
|
|
213
|
+
409,
|
|
214
|
+
'This channel already has another session',
|
|
215
|
+
'session_conflict',
|
|
216
|
+
);
|
|
207
217
|
sessionId =
|
|
208
218
|
binding?.session_id ??
|
|
209
219
|
input.sessionId ??
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ChannelError,
|
|
5
5
|
channelRequestHash,
|
|
6
6
|
channelOrigin,
|
|
7
|
+
delegationRequiredError,
|
|
7
8
|
requireChannelDelegation,
|
|
8
9
|
type ChannelTurnInput,
|
|
9
10
|
type ChannelTurnOutcome,
|
|
@@ -311,7 +312,11 @@ import type {
|
|
|
311
312
|
FeedbackSettlement,
|
|
312
313
|
FeedbackTarget,
|
|
313
314
|
} from '../feedback/contract';
|
|
314
|
-
import {
|
|
315
|
+
import {
|
|
316
|
+
readDelegation,
|
|
317
|
+
resolveTurnDelegation,
|
|
318
|
+
WorkersUcanService,
|
|
319
|
+
} from './ucan-service';
|
|
315
320
|
import type { UcanDelegation } from '../plugin-api/types';
|
|
316
321
|
|
|
317
322
|
/** What one attempt's agent build reads from the request body. */
|
|
@@ -540,6 +545,17 @@ interface StoredDelegation {
|
|
|
540
545
|
at: number;
|
|
541
546
|
}
|
|
542
547
|
|
|
548
|
+
/** The user's delegation as the object holds it in memory. */
|
|
549
|
+
interface CachedDelegation {
|
|
550
|
+
raw: string;
|
|
551
|
+
/**
|
|
552
|
+
* Unix seconds: the delegation's effective expiry. Taken from where the
|
|
553
|
+
* delegation arrived (the shell's validation, the deposit record) or read
|
|
554
|
+
* off the token itself; absent only when the token cannot be parsed.
|
|
555
|
+
*/
|
|
556
|
+
expiration?: number;
|
|
557
|
+
}
|
|
558
|
+
|
|
543
559
|
interface FlushResult {
|
|
544
560
|
uploaded: boolean;
|
|
545
561
|
/** Raw file size (skips) or bytes sent upstream (uploads). */
|
|
@@ -761,7 +777,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
761
777
|
* events of one request (a soft then a hard prune) never race.
|
|
762
778
|
*/
|
|
763
779
|
private contextStatsWrites: Promise<void> = Promise.resolve();
|
|
764
|
-
private readonly delegations = new Map<string,
|
|
780
|
+
private readonly delegations = new Map<string, CachedDelegation>();
|
|
765
781
|
/** Epoch-ms until which a room-state delegation lookup is not retried. */
|
|
766
782
|
private delegationMissUntil = 0;
|
|
767
783
|
private dirty = false;
|
|
@@ -977,9 +993,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
977
993
|
this.delegations.get(identity.userDid)?.raw ??
|
|
978
994
|
(await this.ctx.storage.get<StoredDelegation>(META_DELEGATION))?.raw;
|
|
979
995
|
delegationReplaced = known !== identity.ucanDelegation;
|
|
980
|
-
this.delegations.
|
|
981
|
-
|
|
982
|
-
|
|
996
|
+
const cached = this.delegations.get(identity.userDid);
|
|
997
|
+
if (
|
|
998
|
+
cached?.raw !== identity.ucanDelegation ||
|
|
999
|
+
typeof cached.expiration !== 'number'
|
|
1000
|
+
)
|
|
1001
|
+
this.delegations.set(
|
|
1002
|
+
identity.userDid,
|
|
1003
|
+
await this.delegationRecord(
|
|
1004
|
+
identity.ucanDelegation,
|
|
1005
|
+
identity.ucanDelegationExpiration,
|
|
1006
|
+
),
|
|
1007
|
+
);
|
|
983
1008
|
// Remember a new one for turns that arrive without the header.
|
|
984
1009
|
if (delegationReplaced)
|
|
985
1010
|
await this.ctx.storage.put(META_DELEGATION, {
|
|
@@ -1125,7 +1150,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1125
1150
|
const cached =
|
|
1126
1151
|
await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
|
|
1127
1152
|
if (live(cached)) {
|
|
1128
|
-
this.delegations.set(
|
|
1153
|
+
this.delegations.set(
|
|
1154
|
+
userDid,
|
|
1155
|
+
await this.delegationRecord(cached.raw, cached.expiration),
|
|
1156
|
+
);
|
|
1129
1157
|
return;
|
|
1130
1158
|
}
|
|
1131
1159
|
if (Date.now() < this.delegationMissUntil) return;
|
|
@@ -1156,7 +1184,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1156
1184
|
}
|
|
1157
1185
|
: null;
|
|
1158
1186
|
if (live(deposited)) {
|
|
1159
|
-
this.delegations.set(
|
|
1187
|
+
this.delegations.set(
|
|
1188
|
+
userDid,
|
|
1189
|
+
await this.delegationRecord(deposited.raw, deposited.expiration),
|
|
1190
|
+
);
|
|
1160
1191
|
await this.ctx.storage.put(META_DELEGATION, deposited);
|
|
1161
1192
|
console.log(
|
|
1162
1193
|
`[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
|
|
@@ -1171,6 +1202,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1171
1202
|
this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
|
|
1172
1203
|
}
|
|
1173
1204
|
|
|
1205
|
+
/**
|
|
1206
|
+
* The in-memory record of a delegation: the expiry where it arrived
|
|
1207
|
+
* stated one (the shell validated the token and read it off the chain),
|
|
1208
|
+
* else the token's own effective expiry along its proof chain. A token
|
|
1209
|
+
* that cannot be parsed keeps no expiry; channel turns refuse it.
|
|
1210
|
+
*/
|
|
1211
|
+
private async delegationRecord(
|
|
1212
|
+
raw: string,
|
|
1213
|
+
expiration: number | undefined,
|
|
1214
|
+
): Promise<CachedDelegation> {
|
|
1215
|
+
if (typeof expiration === 'number' && Number.isFinite(expiration))
|
|
1216
|
+
return { raw, expiration };
|
|
1217
|
+
try {
|
|
1218
|
+
const grant = await readDelegation(raw);
|
|
1219
|
+
return typeof grant.expiration === 'number'
|
|
1220
|
+
? { raw, expiration: grant.expiration }
|
|
1221
|
+
: { raw };
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
console.warn(
|
|
1224
|
+
`[user-do] could not read the expiry of a delegation: ${err instanceof Error ? err.message : String(err)}`,
|
|
1225
|
+
);
|
|
1226
|
+
return { raw };
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1174
1230
|
private async boot(userDid: string): Promise<void> {
|
|
1175
1231
|
const stored = await this.ctx.storage.get<string>(META_USER_DID);
|
|
1176
1232
|
if (stored && stored !== userDid)
|
|
@@ -1616,7 +1672,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1616
1672
|
this.channelTurns = new ChannelTurns(liveDb, {
|
|
1617
1673
|
createSession: async (identity, markerTxnId) => {
|
|
1618
1674
|
const room = await this.gateway.resolveUserRoom(identity.userDid);
|
|
1619
|
-
if (!room)
|
|
1675
|
+
if (!room)
|
|
1676
|
+
throw new ChannelError(
|
|
1677
|
+
428,
|
|
1678
|
+
'Companion room is not ready',
|
|
1679
|
+
'room_not_ready',
|
|
1680
|
+
);
|
|
1620
1681
|
return (
|
|
1621
1682
|
await this.createSession(identity, {
|
|
1622
1683
|
roomId: room.roomId,
|
|
@@ -1633,7 +1694,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1633
1694
|
!room ||
|
|
1634
1695
|
session.roomId !== room.roomId
|
|
1635
1696
|
)
|
|
1636
|
-
throw new ChannelError(
|
|
1697
|
+
throw new ChannelError(
|
|
1698
|
+
404,
|
|
1699
|
+
'Companion session not found',
|
|
1700
|
+
'session_not_found',
|
|
1701
|
+
);
|
|
1637
1702
|
const encryption = await this.gateway.getRoomStateEvent(
|
|
1638
1703
|
room.roomId,
|
|
1639
1704
|
'm.room.encryption',
|
|
@@ -1645,11 +1710,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1645
1710
|
!('algorithm' in parsed) ||
|
|
1646
1711
|
parsed.algorithm !== 'm.megolm.v1.aes-sha2'
|
|
1647
1712
|
)
|
|
1648
|
-
throw new ChannelError(
|
|
1713
|
+
throw new ChannelError(
|
|
1714
|
+
409,
|
|
1715
|
+
'Companion room must be encrypted',
|
|
1716
|
+
'room_not_encrypted',
|
|
1717
|
+
);
|
|
1649
1718
|
},
|
|
1719
|
+
// Checked again as the run starts: admission may have waited.
|
|
1650
1720
|
requireDelegation: async (identity) =>
|
|
1651
1721
|
requireChannelDelegation(
|
|
1652
|
-
|
|
1722
|
+
this.delegations.get(identity.userDid),
|
|
1723
|
+
Math.floor(Date.now() / 1000),
|
|
1653
1724
|
),
|
|
1654
1725
|
getRun: (runId) => this.runStore!.get(runId),
|
|
1655
1726
|
getPlan: (runId) => this.runStore!.getPlan(runId),
|
|
@@ -2558,16 +2629,52 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2558
2629
|
throw new ChannelError(
|
|
2559
2630
|
403,
|
|
2560
2631
|
'Channel grants cannot become tool authority',
|
|
2632
|
+
'channel_grant_not_tool_authority',
|
|
2561
2633
|
);
|
|
2562
2634
|
// The shell (the only caller) checked the binding just before.
|
|
2563
|
-
|
|
2635
|
+
// The delegation is checked BEFORE the object boots: a cold boot
|
|
2636
|
+
// loads the owner copy under it, and a user without a usable one
|
|
2637
|
+
// must get `delegation_required`, not a boot failure. Hydration
|
|
2638
|
+
// reads the cached copy, then the deposited room state (throttled
|
|
2639
|
+
// after a miss); an in-memory one that no longer passes is
|
|
2640
|
+
// re-read too, in case a newer deposit did not reach this object.
|
|
2641
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
2642
|
+
try {
|
|
2643
|
+
requireChannelDelegation(
|
|
2644
|
+
this.delegations.get(identity.userDid),
|
|
2645
|
+
nowSeconds,
|
|
2646
|
+
);
|
|
2647
|
+
} catch {
|
|
2648
|
+
await this.hydrateDelegation(identity.userDid);
|
|
2649
|
+
requireChannelDelegation(
|
|
2650
|
+
this.delegations.get(identity.userDid),
|
|
2651
|
+
nowSeconds,
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
try {
|
|
2655
|
+
await this.ready(identity);
|
|
2656
|
+
} catch (error) {
|
|
2657
|
+
// A delegation that lacks the file-storage grant over
|
|
2658
|
+
// `/.oracles` cannot load the owner copy: the user must
|
|
2659
|
+
// authorize again, exactly as without a delegation. (Boot throws
|
|
2660
|
+
// the failure as its RPC envelope, so it is parsed, not matched
|
|
2661
|
+
// by class.)
|
|
2662
|
+
if (parseOwnerCopyFailure(error)?.code === 'NO_VFS_DELEGATION')
|
|
2663
|
+
throw delegationRequiredError();
|
|
2664
|
+
throw error;
|
|
2665
|
+
}
|
|
2564
2666
|
return {
|
|
2565
2667
|
ok: true,
|
|
2566
2668
|
result: await this.channelTurns!.submit(identity, input, requestHash),
|
|
2567
2669
|
};
|
|
2568
2670
|
} catch (error) {
|
|
2569
2671
|
if (error instanceof ChannelError)
|
|
2570
|
-
return {
|
|
2672
|
+
return {
|
|
2673
|
+
ok: false,
|
|
2674
|
+
status: error.status,
|
|
2675
|
+
code: error.code,
|
|
2676
|
+
message: error.message,
|
|
2677
|
+
};
|
|
2571
2678
|
throw error;
|
|
2572
2679
|
}
|
|
2573
2680
|
}
|
|
@@ -2891,7 +2998,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2891
2998
|
raw: string,
|
|
2892
2999
|
expiration?: number,
|
|
2893
3000
|
): Promise<void> {
|
|
2894
|
-
this.delegations.set(
|
|
3001
|
+
this.delegations.set(
|
|
3002
|
+
userDid,
|
|
3003
|
+
await this.delegationRecord(raw, expiration),
|
|
3004
|
+
);
|
|
2895
3005
|
this.delegationMissUntil = 0;
|
|
2896
3006
|
await this.ctx.storage.put(META_DELEGATION, {
|
|
2897
3007
|
raw,
|
|
@@ -3001,12 +3111,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
3001
3111
|
await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
|
|
3002
3112
|
const inMemory = this.delegations.get(userDid);
|
|
3003
3113
|
if (inMemory) {
|
|
3114
|
+
const expiration = inMemory.expiration ?? stored?.expiration;
|
|
3004
3115
|
return {
|
|
3005
3116
|
present: true,
|
|
3006
3117
|
source: 'memory',
|
|
3007
|
-
...(typeof
|
|
3008
|
-
? { expiration: stored.expiration }
|
|
3009
|
-
: {}),
|
|
3118
|
+
...(typeof expiration === 'number' ? { expiration } : {}),
|
|
3010
3119
|
};
|
|
3011
3120
|
}
|
|
3012
3121
|
if (stored?.raw) {
|
|
@@ -4076,7 +4185,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4076
4185
|
if (live.attemptSource !== 'begin')
|
|
4077
4186
|
await assertChannelAttemptAllowed(req.identity, this.env);
|
|
4078
4187
|
requireChannelDelegation(
|
|
4079
|
-
|
|
4188
|
+
this.delegations.get(req.identity.userDid),
|
|
4189
|
+
Math.floor(Date.now() / 1000),
|
|
4080
4190
|
);
|
|
4081
4191
|
}
|
|
4082
4192
|
// An attempt that starts from `admitting` never reached the agent (the
|
package/src/shell/app.ts
CHANGED
|
@@ -139,6 +139,20 @@ function errorText(err: unknown): string {
|
|
|
139
139
|
return err instanceof Error ? err.message : String(err);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Longest error message an unexpected channel failure logs. */
|
|
143
|
+
const CHANNEL_FAILURE_LOG_CHARS = 300;
|
|
144
|
+
|
|
145
|
+
/** `<name>: <message>` of an unexpected channel failure, the message bounded. */
|
|
146
|
+
function describeChannelFailure(err: unknown): string {
|
|
147
|
+
const name = err instanceof Error ? err.name : typeof err;
|
|
148
|
+
const message = errorText(err);
|
|
149
|
+
return `${name}: ${
|
|
150
|
+
message.length > CHANNEL_FAILURE_LOG_CHARS
|
|
151
|
+
? `${message.slice(0, CHANNEL_FAILURE_LOG_CHARS)}…`
|
|
152
|
+
: message
|
|
153
|
+
}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
142
156
|
/** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
|
|
143
157
|
function isAbortRejection(err: unknown): boolean {
|
|
144
158
|
return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
|
|
@@ -405,16 +419,21 @@ export function createShell(
|
|
|
405
419
|
app.post('/channels/turn', async (c) => {
|
|
406
420
|
try {
|
|
407
421
|
if (!c.env.CHANNEL_SERVICE_DID)
|
|
408
|
-
|
|
422
|
+
throw new ChannelError(
|
|
423
|
+
503,
|
|
424
|
+
'Channels are not configured',
|
|
425
|
+
'not_configured',
|
|
426
|
+
);
|
|
409
427
|
const raw = await readChannelBody(c.req.raw);
|
|
410
428
|
let json: unknown;
|
|
411
429
|
try {
|
|
412
430
|
json = JSON.parse(raw);
|
|
413
431
|
} catch {
|
|
414
|
-
throw new ChannelError(400, 'Invalid JSON request');
|
|
432
|
+
throw new ChannelError(400, 'Invalid JSON request', 'invalid_request');
|
|
415
433
|
}
|
|
416
434
|
const parsed = ChannelTurnBody.safeParse(json);
|
|
417
|
-
if (!parsed.success)
|
|
435
|
+
if (!parsed.success)
|
|
436
|
+
throw new ChannelError(400, 'Invalid channel turn', 'invalid_request');
|
|
418
437
|
const identity = await authenticateChannel(
|
|
419
438
|
c.req.raw.headers,
|
|
420
439
|
raw,
|
|
@@ -431,7 +450,7 @@ export function createShell(
|
|
|
431
450
|
})
|
|
432
451
|
).success
|
|
433
452
|
)
|
|
434
|
-
throw new ChannelError(429, 'Too many requests');
|
|
453
|
+
throw new ChannelError(429, 'Too many requests', 'rate_limited');
|
|
435
454
|
await assertActiveChannelBinding(identity, c.env);
|
|
436
455
|
const outcome = await userStub(c.env, identity.userDid).channelTurn(
|
|
437
456
|
identity,
|
|
@@ -439,7 +458,10 @@ export function createShell(
|
|
|
439
458
|
await channelRequestHash(raw),
|
|
440
459
|
);
|
|
441
460
|
if (!outcome.ok)
|
|
442
|
-
return c.json(
|
|
461
|
+
return c.json(
|
|
462
|
+
{ code: outcome.code, message: outcome.message },
|
|
463
|
+
outcome.status,
|
|
464
|
+
);
|
|
443
465
|
const result = outcome.result;
|
|
444
466
|
return c.json(
|
|
445
467
|
result,
|
|
@@ -447,9 +469,20 @@ export function createShell(
|
|
|
447
469
|
);
|
|
448
470
|
} catch (error) {
|
|
449
471
|
if (error instanceof ChannelError)
|
|
450
|
-
return c.json(
|
|
472
|
+
return c.json(
|
|
473
|
+
{ code: error.code, message: error.message },
|
|
474
|
+
error.status,
|
|
475
|
+
);
|
|
476
|
+
// The cause only, never the request or its tokens: a 503 the gateway
|
|
477
|
+
// retries must leave a trace of why it happened.
|
|
478
|
+
console.error(
|
|
479
|
+
`[channels] turn failed unexpectedly: ${describeChannelFailure(error)}`,
|
|
480
|
+
);
|
|
451
481
|
return c.json(
|
|
452
|
-
{
|
|
482
|
+
{
|
|
483
|
+
code: 'unavailable',
|
|
484
|
+
message: 'Channel turn is temporarily unavailable',
|
|
485
|
+
},
|
|
453
486
|
503,
|
|
454
487
|
);
|
|
455
488
|
}
|
|
@@ -1084,7 +1117,7 @@ export function createShell(
|
|
|
1084
1117
|
// Thrown on purpose with a status and a message meant for the client.
|
|
1085
1118
|
if (err instanceof HTTPException) return err.getResponse();
|
|
1086
1119
|
if (err instanceof ChannelError)
|
|
1087
|
-
return c.json({ message: err.message }, err.status);
|
|
1120
|
+
return c.json({ code: err.code, message: err.message }, err.status);
|
|
1088
1121
|
const requestId = errorRequestId(c.req.header('x-request-id'));
|
|
1089
1122
|
console.error(
|
|
1090
1123
|
`[shell] ${c.req.method} ${loggedRoute(c)} failed (request ${requestId}):`,
|