@ixo/oracle-runtime-workers 0.13.0 → 0.15.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 +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
package/src/do/sse-stream.ts
CHANGED
|
@@ -20,11 +20,14 @@
|
|
|
20
20
|
* that reconnects can ask for `?after=<id>`.
|
|
21
21
|
*/
|
|
22
22
|
import {
|
|
23
|
+
BYO_FALLBACK_KIND,
|
|
23
24
|
classifyLlmError,
|
|
24
25
|
isOperatorFault,
|
|
25
26
|
redactOperatorFault,
|
|
26
27
|
} from '../llm/provider-error';
|
|
28
|
+
import { reportsUnknownOutcome } from '@ixo/common/ai/frontend-bridge';
|
|
27
29
|
import { type AIMessageChunk, ToolMessage } from '@langchain/core/messages';
|
|
30
|
+
import { harnessLimitOf, type HarnessLimitError } from '../core/turn-budget';
|
|
28
31
|
import { RunBuffer, type RunFrame } from './run-buffer';
|
|
29
32
|
|
|
30
33
|
/** Where the producer puts frames — a `RunBuffer`, or a test sink. */
|
|
@@ -303,6 +306,32 @@ export async function runTurnFrames(
|
|
|
303
306
|
...(input.runId ? { runId: input.runId } : {}),
|
|
304
307
|
});
|
|
305
308
|
|
|
309
|
+
// The turn hit its budget (tokens, tool attempts, deadline): the abort
|
|
310
|
+
// was the harness's, not the user's, so the client gets a terminal
|
|
311
|
+
// `error` naming the limit before `done` — not a silent "aborted".
|
|
312
|
+
const limitFailure = (limit: HarnessLimitError) => {
|
|
313
|
+
flushOrphans();
|
|
314
|
+
write('reasoning', {
|
|
315
|
+
sessionId,
|
|
316
|
+
requestId,
|
|
317
|
+
reasoning: '',
|
|
318
|
+
isComplete: true,
|
|
319
|
+
timestamp: new Date().toISOString(),
|
|
320
|
+
});
|
|
321
|
+
write('error', {
|
|
322
|
+
error: limit.message,
|
|
323
|
+
kind: limit.kind,
|
|
324
|
+
limit: limit.limit,
|
|
325
|
+
source: 'platform',
|
|
326
|
+
retryable: false,
|
|
327
|
+
sessionId,
|
|
328
|
+
requestId,
|
|
329
|
+
timestamp: new Date().toISOString(),
|
|
330
|
+
});
|
|
331
|
+
doneFrame({ failed: true });
|
|
332
|
+
return { status: 'failed' as const, fullText: fullContent, error: limit };
|
|
333
|
+
};
|
|
334
|
+
|
|
306
335
|
const flushOrphans = () => {
|
|
307
336
|
for (const evt of actionCallMap.values()) {
|
|
308
337
|
write('action_call', {
|
|
@@ -362,6 +391,15 @@ export async function runTurnFrames(
|
|
|
362
391
|
metadata?: Record<string, unknown>;
|
|
363
392
|
tags?: string[];
|
|
364
393
|
};
|
|
394
|
+
if (evt.event === 'on_custom_event' && evt.name === BYO_FALLBACK_KIND) {
|
|
395
|
+
// A BYO model the provider refused mid-turn (byo-model-fallback.ts):
|
|
396
|
+
// the same `error`-channel notice the pre-turn fallbacks send, in
|
|
397
|
+
// stream order — ahead of the platform model's reply to that call.
|
|
398
|
+
const notice = evt.data;
|
|
399
|
+
if (notice && typeof notice === 'object')
|
|
400
|
+
write('error', { ...notice, sessionId, requestId });
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
365
403
|
if (evt.event === 'on_chat_model_end') {
|
|
366
404
|
if (isInternalModelEvent(evt)) continue;
|
|
367
405
|
const output = (evt.data as { output?: AIMessageChunk })?.output;
|
|
@@ -441,7 +479,12 @@ export async function runTurnFrames(
|
|
|
441
479
|
const action = actionCallMap.get(evt.run_id);
|
|
442
480
|
if (action) {
|
|
443
481
|
const parsed = safeParseToolContent(output?.content);
|
|
444
|
-
|
|
482
|
+
// An unknown outcome (FRONTEND_OUTCOME_UNKNOWN) is not a failure:
|
|
483
|
+
// the action may have run, so the client must not offer a retry.
|
|
484
|
+
// It ends as `done` with the unknown result as its output.
|
|
485
|
+
const failed =
|
|
486
|
+
!reportsUnknownOutcome(parsed) &&
|
|
487
|
+
(parsed?.success === false || Boolean(parsed?.error));
|
|
445
488
|
write('action_call', {
|
|
446
489
|
...action,
|
|
447
490
|
output: text,
|
|
@@ -579,19 +622,25 @@ export async function runTurnFrames(
|
|
|
579
622
|
doneFrame();
|
|
580
623
|
return { status: 'completed', fullText: fullContent };
|
|
581
624
|
}
|
|
625
|
+
const reasonLimit = harnessLimitOf(abortController.signal.reason);
|
|
626
|
+
if (reasonLimit) return limitFailure(reasonLimit);
|
|
582
627
|
// Aborted (POST /messages/abort, or superseded): close the stream
|
|
583
628
|
// cleanly so the UI leaves its "thinking" state.
|
|
584
629
|
flushOrphans();
|
|
585
630
|
doneFrame({ aborted: true });
|
|
586
631
|
return { status: 'aborted', fullText: fullContent };
|
|
587
632
|
} catch (error) {
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
const
|
|
591
|
-
abortController.signal.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
633
|
+
// A limit reaches here wrapped by LangChain's middleware layers; the
|
|
634
|
+
// original carries the `limit` the client is told about.
|
|
635
|
+
const limit =
|
|
636
|
+
harnessLimitOf(error) ?? harnessLimitOf(abortController.signal.reason);
|
|
637
|
+
if (limit) return limitFailure(limit);
|
|
638
|
+
// The run's signal is the only authority: LangGraph rethrows the
|
|
639
|
+
// signal's reason (whatever the aborter passed), not always an
|
|
640
|
+
// `AbortError`, while an `AbortError` or "aborted" message with the
|
|
641
|
+
// signal untouched is a provider or tool timing out — a failure the
|
|
642
|
+
// client must see as one.
|
|
643
|
+
if (abortController.signal.aborted) {
|
|
595
644
|
flushOrphans();
|
|
596
645
|
doneFrame({ aborted: true });
|
|
597
646
|
return { status: 'aborted', fullText: fullContent };
|
|
@@ -686,7 +735,9 @@ export function createSseSubscriberStream(
|
|
|
686
735
|
for (const frame of input.replay) write(frame);
|
|
687
736
|
if (closed) return;
|
|
688
737
|
if (input.buffer && !input.buffer.isClosed) {
|
|
689
|
-
|
|
738
|
+
// A buffer that closes without a `done` (it never should) still
|
|
739
|
+
// ends the response instead of leaving it on heartbeats.
|
|
740
|
+
unsubscribe = input.buffer.subscribe(write, finish);
|
|
690
741
|
} else {
|
|
691
742
|
if (input.trailer)
|
|
692
743
|
write({
|
package/src/do/transcript.ts
CHANGED
|
@@ -13,6 +13,13 @@ import {
|
|
|
13
13
|
isAttachmentViewMessage,
|
|
14
14
|
} from '../attachments/retention';
|
|
15
15
|
import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
|
|
16
|
+
import {
|
|
17
|
+
stripTurnTimeNote,
|
|
18
|
+
TURN_TIME_NOTE_KWARG,
|
|
19
|
+
} from '../core/turn-time-note';
|
|
20
|
+
import { ChannelOrigin } from '../channels/contract';
|
|
21
|
+
import { CREATE_ARTIFACT_TOOL, createArtifactParts } from '../artifacts/tool';
|
|
22
|
+
import { planText } from '../delivery/schema';
|
|
16
23
|
import type {
|
|
17
24
|
ThreadMessageAnchor,
|
|
18
25
|
ThreadMessageRow,
|
|
@@ -45,6 +52,7 @@ export interface MessageDto {
|
|
|
45
52
|
isReasoning?: boolean;
|
|
46
53
|
attachment?: AttachmentMeta;
|
|
47
54
|
attachments?: AttachmentMeta[];
|
|
55
|
+
metadata?: { 'org.ixo.qi.origin': ChannelOrigin };
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export interface ListMessagesResponse {
|
|
@@ -108,6 +116,25 @@ export function contentToText(content: BaseMessage['content']): string {
|
|
|
108
116
|
return String(content);
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
/**
|
|
120
|
+
* A stored message's content as a person (or a service acting for one)
|
|
121
|
+
* reads it: a user message loses the "current time" note the model was
|
|
122
|
+
* given with it, when it still starts with exactly the note recorded on it.
|
|
123
|
+
* Every other message is returned as stored.
|
|
124
|
+
*/
|
|
125
|
+
export function visibleContent(message: BaseMessage): BaseMessage['content'] {
|
|
126
|
+
if (message.type !== 'human') return message.content;
|
|
127
|
+
return stripTurnTimeNote(
|
|
128
|
+
message.content,
|
|
129
|
+
message.additional_kwargs?.[TURN_TIME_NOTE_KWARG],
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `visibleContent` as text (titles, mirrors, indexing). */
|
|
134
|
+
export function visibleText(message: BaseMessage): string {
|
|
135
|
+
return contentToText(visibleContent(message));
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
/**
|
|
112
139
|
* The summarization middleware's bookkeeping message — never shown to users.
|
|
113
140
|
* LangChain 1.4 writes it as a HUMAN message tagged
|
|
@@ -132,6 +159,9 @@ export async function transformTranscript(
|
|
|
132
159
|
messages: BaseMessage[],
|
|
133
160
|
): Promise<ListMessagesResponse> {
|
|
134
161
|
const acc: MessageDto[] = [];
|
|
162
|
+
// The first listed reply that made each tool call: a tool result folds
|
|
163
|
+
// into it (looked up, not searched, so long transcripts stay linear).
|
|
164
|
+
const callers = new Map<string, MessageDto>();
|
|
135
165
|
for (const message of messages) {
|
|
136
166
|
const toolMsg = message.type === 'tool' ? (message as ToolMessage) : null;
|
|
137
167
|
const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
|
|
@@ -152,7 +182,7 @@ export async function transformTranscript(
|
|
|
152
182
|
(kw.attachment ? [kw.attachment as AttachmentMeta] : undefined))
|
|
153
183
|
: undefined;
|
|
154
184
|
const attachment = attachments?.[0];
|
|
155
|
-
const textContent =
|
|
185
|
+
const textContent = visibleText(message);
|
|
156
186
|
const toolCalls = (message as AIMessage).tool_calls;
|
|
157
187
|
const dto: MessageDto = {
|
|
158
188
|
type: message.type === 'ai' ? 'ai' : 'human',
|
|
@@ -175,7 +205,13 @@ export async function transformTranscript(
|
|
|
175
205
|
if (reasoning) dto.reasoning = reasoning;
|
|
176
206
|
if (attachment) dto.attachment = attachment;
|
|
177
207
|
if (attachments?.length) dto.attachments = attachments;
|
|
208
|
+
if (message.type === 'human') {
|
|
209
|
+
const origin = ChannelOrigin.safeParse(kw['org.ixo.qi.origin']);
|
|
210
|
+
if (origin.success) dto.metadata = { 'org.ixo.qi.origin': origin.data };
|
|
211
|
+
}
|
|
178
212
|
acc.push(dto);
|
|
213
|
+
for (const call of dto.toolCalls ?? [])
|
|
214
|
+
if (!callers.has(call.id)) callers.set(call.id, dto);
|
|
179
215
|
}
|
|
180
216
|
|
|
181
217
|
if (toolMsg) {
|
|
@@ -188,11 +224,18 @@ export async function transformTranscript(
|
|
|
188
224
|
toolMsg.tool_call_id ??
|
|
189
225
|
kwargs?.tool_call_id ??
|
|
190
226
|
(await uuidFromString(JSON.stringify(kwargs?.args)));
|
|
191
|
-
const
|
|
192
|
-
m.toolCalls?.some((t) => t.id === toolCallId),
|
|
193
|
-
);
|
|
194
|
-
const el = idx !== -1 ? acc[idx] : undefined;
|
|
227
|
+
const el = callers.get(toolCallId);
|
|
195
228
|
if (el) {
|
|
229
|
+
// A chat reply that ended in `create_artifact` wrote no text of its
|
|
230
|
+
// own: list what the user received, the message, link and question.
|
|
231
|
+
const call = el.toolCalls?.find((t) => t.id === toolCallId);
|
|
232
|
+
if (call?.name === CREATE_ARTIFACT_TOOL && !el.content.trim())
|
|
233
|
+
el.content = planText({
|
|
234
|
+
parts: createArtifactParts(
|
|
235
|
+
call.args,
|
|
236
|
+
contentToText(toolMsg.content),
|
|
237
|
+
),
|
|
238
|
+
});
|
|
196
239
|
el.toolCalls = el.toolCalls?.map((t) =>
|
|
197
240
|
t.id === toolCallId
|
|
198
241
|
? {
|
package/src/do/turn-body.ts
CHANGED
|
@@ -64,7 +64,34 @@ const reject = (message: string): ParsedTurnBody => ({
|
|
|
64
64
|
message,
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
type ToolDeclaration = NonNullable<TurnBody['tools']>[number];
|
|
68
|
+
|
|
69
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A client-declared tool (or AG-UI action): name, description, schema. */
|
|
74
|
+
function toolDeclaration(value: unknown): ToolDeclaration | null {
|
|
75
|
+
if (
|
|
76
|
+
!isRecord(value) ||
|
|
77
|
+
typeof value.name !== 'string' ||
|
|
78
|
+
typeof value.description !== 'string' ||
|
|
79
|
+
!isRecord(value.schema)
|
|
80
|
+
)
|
|
81
|
+
return null;
|
|
82
|
+
return {
|
|
83
|
+
name: value.name,
|
|
84
|
+
description: value.description,
|
|
85
|
+
schema: value.schema,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse and validate a raw request body. Never throws. Every field the
|
|
91
|
+
* runtime reads is checked for its type here, so a wrong one is a 400 and
|
|
92
|
+
* not a failed turn later; the accepted fields the runtime ignores
|
|
93
|
+
* (`homeServer`, `mcpInvocations`, `requestId`) are not passed on.
|
|
94
|
+
*/
|
|
68
95
|
export function parseTurnBody(raw: string): ParsedTurnBody {
|
|
69
96
|
let parsed: unknown;
|
|
70
97
|
try {
|
|
@@ -72,17 +99,23 @@ export function parseTurnBody(raw: string): ParsedTurnBody {
|
|
|
72
99
|
} catch {
|
|
73
100
|
return reject('Invalid JSON body');
|
|
74
101
|
}
|
|
75
|
-
if (
|
|
102
|
+
if (!isRecord(parsed)) {
|
|
76
103
|
return reject('Request body must be a JSON object');
|
|
77
104
|
}
|
|
78
|
-
const
|
|
79
|
-
const unknownProp = Object.keys(record).find((k) => !TURN_BODY_KEYS.has(k));
|
|
105
|
+
const unknownProp = Object.keys(parsed).find((k) => !TURN_BODY_KEYS.has(k));
|
|
80
106
|
if (unknownProp !== undefined) {
|
|
81
107
|
return reject(`property ${unknownProp} should not exist`);
|
|
82
108
|
}
|
|
109
|
+
// A field sent as `null` is a field not sent (clients serialise unset
|
|
110
|
+
// optionals either way).
|
|
111
|
+
const record = Object.fromEntries(
|
|
112
|
+
Object.entries(parsed).filter(([, value]) => value !== null),
|
|
113
|
+
);
|
|
83
114
|
if (typeof record.message !== 'string' || record.message.length === 0) {
|
|
84
115
|
return reject('message is required');
|
|
85
116
|
}
|
|
117
|
+
const { stream, returnAllMessages, timezone, model, metadata, attachments } =
|
|
118
|
+
record;
|
|
86
119
|
if (
|
|
87
120
|
record.multitask !== undefined &&
|
|
88
121
|
record.multitask !== 'interrupt' &&
|
|
@@ -90,5 +123,72 @@ export function parseTurnBody(raw: string): ParsedTurnBody {
|
|
|
90
123
|
) {
|
|
91
124
|
return reject('multitask must be one of: interrupt, enqueue');
|
|
92
125
|
}
|
|
93
|
-
|
|
126
|
+
for (const [name, value] of [
|
|
127
|
+
['stream', stream],
|
|
128
|
+
['returnAllMessages', returnAllMessages],
|
|
129
|
+
] as const)
|
|
130
|
+
if (value !== undefined && typeof value !== 'boolean')
|
|
131
|
+
return reject(`${name} must be a boolean`);
|
|
132
|
+
for (const [name, value] of [
|
|
133
|
+
['timezone', timezone],
|
|
134
|
+
['model', model],
|
|
135
|
+
['homeServer', record.homeServer],
|
|
136
|
+
['requestId', record.requestId],
|
|
137
|
+
] as const)
|
|
138
|
+
if (value !== undefined && typeof value !== 'string')
|
|
139
|
+
return reject(`${name} must be a string`);
|
|
140
|
+
if (metadata !== undefined && !isRecord(metadata))
|
|
141
|
+
return reject('metadata must be an object');
|
|
142
|
+
if (attachments !== undefined && !Array.isArray(attachments))
|
|
143
|
+
return reject('attachments must be an array');
|
|
144
|
+
let tools: ToolDeclaration[] | undefined;
|
|
145
|
+
if (record.tools !== undefined) {
|
|
146
|
+
if (!Array.isArray(record.tools)) return reject('tools must be an array');
|
|
147
|
+
tools = [];
|
|
148
|
+
for (const item of record.tools) {
|
|
149
|
+
const tool = toolDeclaration(item);
|
|
150
|
+
if (!tool)
|
|
151
|
+
return reject(
|
|
152
|
+
'each tool needs a string name, description and an object schema',
|
|
153
|
+
);
|
|
154
|
+
tools.push(tool);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let agActions: NonNullable<TurnBody['agActions']> | undefined;
|
|
158
|
+
if (record.agActions !== undefined) {
|
|
159
|
+
if (!Array.isArray(record.agActions))
|
|
160
|
+
return reject('agActions must be an array');
|
|
161
|
+
agActions = [];
|
|
162
|
+
for (const item of record.agActions) {
|
|
163
|
+
const action = toolDeclaration(item);
|
|
164
|
+
const hasRender = isRecord(item) ? item.hasRender : undefined;
|
|
165
|
+
if (
|
|
166
|
+
!action ||
|
|
167
|
+
(hasRender !== undefined && typeof hasRender !== 'boolean')
|
|
168
|
+
)
|
|
169
|
+
return reject(
|
|
170
|
+
'each agAction needs a string name, description and an object schema',
|
|
171
|
+
);
|
|
172
|
+
agActions.push(
|
|
173
|
+
hasRender === undefined ? action : { ...action, hasRender },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
body: {
|
|
180
|
+
message: record.message,
|
|
181
|
+
...(typeof stream === 'boolean' ? { stream } : {}),
|
|
182
|
+
...(record.multitask === 'interrupt' || record.multitask === 'enqueue'
|
|
183
|
+
? { multitask: record.multitask }
|
|
184
|
+
: {}),
|
|
185
|
+
...(typeof returnAllMessages === 'boolean' ? { returnAllMessages } : {}),
|
|
186
|
+
...(typeof timezone === 'string' ? { timezone } : {}),
|
|
187
|
+
...(typeof model === 'string' ? { model } : {}),
|
|
188
|
+
...(isRecord(metadata) ? { metadata } : {}),
|
|
189
|
+
...(tools ? { tools } : {}),
|
|
190
|
+
...(agActions ? { agActions } : {}),
|
|
191
|
+
...(Array.isArray(attachments) ? { attachments } : {}),
|
|
192
|
+
},
|
|
193
|
+
};
|
|
94
194
|
}
|
package/src/do/ucan-service.ts
CHANGED
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
signerFromMnemonic,
|
|
18
18
|
type SupportedDID,
|
|
19
19
|
} from '@ixo/ucan';
|
|
20
|
+
import {
|
|
21
|
+
abilityCovers,
|
|
22
|
+
delegationHasCapability,
|
|
23
|
+
resourceCovers,
|
|
24
|
+
} from '../core/runtime-context';
|
|
20
25
|
import type { RuntimeContext, UcanDelegation } from '../plugin-api/types';
|
|
21
26
|
|
|
22
27
|
type MintResult = { invocation: string } | { error: string };
|
|
@@ -28,24 +33,18 @@ export interface UcanServiceOptions {
|
|
|
28
33
|
oracleDid: string;
|
|
29
34
|
signingMnemonic?: string;
|
|
30
35
|
logger?: { warn(msg: string): void; debug?(msg: string): void };
|
|
36
|
+
/** Longest a did.json or UCAN store request may take (default 10 s). */
|
|
37
|
+
fetchTimeoutMs?: number;
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
|
|
41
|
+
|
|
33
42
|
function toSupportedDid(did: string): SupportedDID {
|
|
34
43
|
if (did.startsWith('did:ixo:') || did.startsWith('did:key:'))
|
|
35
44
|
return did as SupportedDID;
|
|
36
45
|
throw new Error(`Unsupported oracle DID method: ${did}`);
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
/** Does a granted ability (`*`, `ns/*`, or an exact ability) cover `required`? */
|
|
40
|
-
export function abilityCovers(granted: string, required: string): boolean {
|
|
41
|
-
if (granted === '*' || granted === required) return true;
|
|
42
|
-
if (granted.endsWith('/*')) {
|
|
43
|
-
const ns = granted.slice(0, -2);
|
|
44
|
-
return required === ns || required.startsWith(`${ns}/`);
|
|
45
|
-
}
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
48
|
/** One capability of a delegation, as the wire carries it. */
|
|
50
49
|
export interface DelegatedCapability {
|
|
51
50
|
can: string;
|
|
@@ -61,14 +60,86 @@ export interface DelegatedCapability {
|
|
|
61
60
|
export async function listDelegationCapabilities(
|
|
62
61
|
delegationCar: string,
|
|
63
62
|
): Promise<DelegatedCapability[]> {
|
|
63
|
+
return (await readDelegation(delegationCar)).capabilities;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What a serialized delegation grants and the window in which it grants it. */
|
|
67
|
+
export interface DelegationGrant {
|
|
68
|
+
capabilities: DelegatedCapability[];
|
|
69
|
+
/** Unix seconds: the earliest expiry along the proof chain; absent = none declared. */
|
|
70
|
+
expiration?: number;
|
|
71
|
+
/** Unix seconds: the latest `nbf` along the proof chain; absent = none declared. */
|
|
72
|
+
notBefore?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A finite number read off a parsed UCAN field, else undefined. */
|
|
76
|
+
function finiteField(node: object, field: 'expiration' | 'notBefore') {
|
|
77
|
+
const value: unknown = Reflect.get(node, field);
|
|
78
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
79
|
+
? value
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The validity window of a delegation and the proofs it carries, along the
|
|
85
|
+
* first proof at each level — the chain the UCAN validator's effective expiry
|
|
86
|
+
* follows. A proof carried only as a link (not embedded) has no fields to read.
|
|
87
|
+
*/
|
|
88
|
+
function chainWindow(node: unknown): {
|
|
89
|
+
expiration?: number;
|
|
90
|
+
notBefore?: number;
|
|
91
|
+
} {
|
|
92
|
+
if (typeof node !== 'object' || node === null) return {};
|
|
93
|
+
const own = {
|
|
94
|
+
expiration: finiteField(node, 'expiration'),
|
|
95
|
+
notBefore: finiteField(node, 'notBefore'),
|
|
96
|
+
};
|
|
97
|
+
const proofs: unknown = Reflect.get(node, 'proofs');
|
|
98
|
+
const parent = Array.isArray(proofs) ? chainWindow(proofs[0]) : {};
|
|
99
|
+
const pick = (
|
|
100
|
+
a: number | undefined,
|
|
101
|
+
b: number | undefined,
|
|
102
|
+
f: (x: number, y: number) => number,
|
|
103
|
+
) => (a === undefined ? b : b === undefined ? a : f(a, b));
|
|
104
|
+
const expiration = pick(own.expiration, parent.expiration, Math.min);
|
|
105
|
+
const notBefore = pick(own.notBefore, parent.notBefore, Math.max);
|
|
106
|
+
return {
|
|
107
|
+
...(expiration !== undefined ? { expiration } : {}),
|
|
108
|
+
...(notBefore !== undefined ? { notBefore } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse a serialized delegation into its grant (no signature checks). */
|
|
113
|
+
export async function readDelegation(
|
|
114
|
+
delegationCar: string,
|
|
115
|
+
): Promise<DelegationGrant> {
|
|
64
116
|
const delegation = await parseDelegation(delegationCar);
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
117
|
+
return {
|
|
118
|
+
capabilities: delegation.capabilities.map((c) => ({
|
|
119
|
+
can: c.can,
|
|
120
|
+
with: c.with,
|
|
121
|
+
...(c.nb !== undefined && c.nb !== null
|
|
122
|
+
? { nb: c.nb as Record<string, unknown> }
|
|
123
|
+
: {}),
|
|
124
|
+
})),
|
|
125
|
+
...chainWindow(delegation),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The delegation a turn runs under: the one the request carried, else the
|
|
131
|
+
* user's stored one (a Matrix turn carries none), with the capabilities it
|
|
132
|
+
* grants (`withCapabilities`). Every capability check of the turn reads it:
|
|
133
|
+
* `requires`, admin-plane tools, the plugins' own mints. Without a UCAN
|
|
134
|
+
* service it carries no capabilities and grants nothing.
|
|
135
|
+
*/
|
|
136
|
+
export async function resolveTurnDelegation(
|
|
137
|
+
requestDelegation: string | undefined,
|
|
138
|
+
storedDelegation: string | undefined,
|
|
139
|
+
ucan: Pick<WorkersUcanService, 'withCapabilities'> | null,
|
|
140
|
+
): Promise<UcanDelegation> {
|
|
141
|
+
const raw = requestDelegation ?? storedDelegation ?? '';
|
|
142
|
+
return ucan ? ucan.withCapabilities(raw) : { raw };
|
|
72
143
|
}
|
|
73
144
|
|
|
74
145
|
/** Upper bound on a delegated invocation's lifetime (mirrors the Node runtime). */
|
|
@@ -85,27 +156,107 @@ export class WorkersUcanService {
|
|
|
85
156
|
string,
|
|
86
157
|
{ value: { token: string; with: string }; expiresAt: number }
|
|
87
158
|
>();
|
|
88
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Store look-ups in progress, keyed like `storeDelegationCache`: callers
|
|
161
|
+
* that miss the cache at the same moment share one fetch. Dropped when it
|
|
162
|
+
* settles, so a failed look-up is retried by the next call.
|
|
163
|
+
*/
|
|
164
|
+
private readonly storeDelegationInFlight = new Map<
|
|
165
|
+
string,
|
|
166
|
+
Promise<ServiceDelegationResult>
|
|
167
|
+
>();
|
|
168
|
+
/** Parsed grants per raw delegation; the validity window is checked at use, never cached away. */
|
|
169
|
+
private readonly grantCache = new Map<string, DelegationGrant>();
|
|
89
170
|
|
|
90
171
|
constructor(private readonly opts: UcanServiceOptions) {}
|
|
91
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A raw delegation with the capabilities it grants, the shape
|
|
175
|
+
* `ctx.ucan.hasCapability` and plugin `requires` read. A token that cannot
|
|
176
|
+
* be parsed grants nothing, and neither does one outside its validity
|
|
177
|
+
* window right now: expired, or not yet valid (`nbf`). The window is
|
|
178
|
+
* compared with the clock on every call — the parse is cached, the
|
|
179
|
+
* verdict is not — and the result carries `expiration`, so a delegation
|
|
180
|
+
* that lapses during the turn stops granting at that moment too. The
|
|
181
|
+
* stored delegation a Matrix turn runs under was validated only when it
|
|
182
|
+
* was deposited; this is what ends it.
|
|
183
|
+
*/
|
|
184
|
+
async withCapabilities(raw: string): Promise<UcanDelegation> {
|
|
185
|
+
if (!raw) return { raw };
|
|
186
|
+
let grant: DelegationGrant;
|
|
187
|
+
try {
|
|
188
|
+
grant = await this.delegationGrant(raw);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
this.opts.logger?.warn(
|
|
191
|
+
`[UCAN] cannot read a delegation's capabilities: ${err instanceof Error ? err.message : String(err)}`,
|
|
192
|
+
);
|
|
193
|
+
return { raw, capabilities: [] };
|
|
194
|
+
}
|
|
195
|
+
const nowSeconds = Date.now() / 1000;
|
|
196
|
+
const lapsed =
|
|
197
|
+
grant.expiration !== undefined && grant.expiration <= nowSeconds;
|
|
198
|
+
const early = grant.notBefore !== undefined && grant.notBefore > nowSeconds;
|
|
199
|
+
if (lapsed || early) {
|
|
200
|
+
this.opts.logger?.warn(
|
|
201
|
+
lapsed
|
|
202
|
+
? `[UCAN] the delegation expired at ${new Date((grant.expiration ?? 0) * 1000).toISOString()}; it grants nothing`
|
|
203
|
+
: `[UCAN] the delegation is not valid before ${new Date((grant.notBefore ?? 0) * 1000).toISOString()}; it grants nothing yet`,
|
|
204
|
+
);
|
|
205
|
+
return {
|
|
206
|
+
raw,
|
|
207
|
+
capabilities: [],
|
|
208
|
+
...(grant.expiration !== undefined
|
|
209
|
+
? { expiration: grant.expiration }
|
|
210
|
+
: {}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
raw,
|
|
215
|
+
capabilities: grant.capabilities.map((c) => ({
|
|
216
|
+
resource: c.with,
|
|
217
|
+
action: c.can,
|
|
218
|
+
})),
|
|
219
|
+
...(grant.expiration !== undefined
|
|
220
|
+
? { expiration: grant.expiration }
|
|
221
|
+
: {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
92
225
|
hasSigningKey(): boolean {
|
|
93
226
|
return Boolean(this.opts.signingMnemonic);
|
|
94
227
|
}
|
|
95
228
|
|
|
96
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* `listDelegationCapabilities`, memoised on the raw token. No validity
|
|
231
|
+
* window: the owner store picks the grant to mint from with it, and the
|
|
232
|
+
* mint refuses an expired delegation itself.
|
|
233
|
+
*/
|
|
97
234
|
async delegationCapabilities(
|
|
98
235
|
delegationCar: string,
|
|
99
236
|
): Promise<DelegatedCapability[]> {
|
|
100
|
-
|
|
237
|
+
return (await this.delegationGrant(delegationCar)).capabilities;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `readDelegation`, memoised on the raw token. */
|
|
241
|
+
private async delegationGrant(
|
|
242
|
+
delegationCar: string,
|
|
243
|
+
): Promise<DelegationGrant> {
|
|
244
|
+
const cached = this.grantCache.get(delegationCar);
|
|
101
245
|
if (cached) return cached;
|
|
102
|
-
const
|
|
103
|
-
if (this.
|
|
104
|
-
const oldest = this.
|
|
105
|
-
if (oldest !== undefined) this.
|
|
246
|
+
const grant = await readDelegation(delegationCar);
|
|
247
|
+
if (this.grantCache.size >= DELEGATION_CAPABILITIES_CACHE_SIZE) {
|
|
248
|
+
const oldest = this.grantCache.keys().next().value;
|
|
249
|
+
if (oldest !== undefined) this.grantCache.delete(oldest);
|
|
106
250
|
}
|
|
107
|
-
this.
|
|
108
|
-
return
|
|
251
|
+
this.grantCache.set(delegationCar, grant);
|
|
252
|
+
return grant;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Bounds one outbound request (headers and body). */
|
|
256
|
+
private fetchSignal(): AbortSignal {
|
|
257
|
+
return AbortSignal.timeout(
|
|
258
|
+
this.opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
259
|
+
);
|
|
109
260
|
}
|
|
110
261
|
|
|
111
262
|
async resolveServiceDid(serviceUrl: string): Promise<string | null> {
|
|
@@ -113,7 +264,9 @@ export class WorkersUcanService {
|
|
|
113
264
|
const origin = new URL(serviceUrl).origin;
|
|
114
265
|
const cached = this.serviceDidCache.get(origin);
|
|
115
266
|
if (cached && cached.expiresAt > Date.now()) return cached.did;
|
|
116
|
-
const res = await fetch(`${origin}/.well-known/did.json
|
|
267
|
+
const res = await fetch(`${origin}/.well-known/did.json`, {
|
|
268
|
+
signal: this.fetchSignal(),
|
|
269
|
+
});
|
|
117
270
|
if (!res.ok) {
|
|
118
271
|
this.opts.logger?.warn(`[UCAN] did.json ${origin}: HTTP ${res.status}`);
|
|
119
272
|
return null;
|
|
@@ -265,7 +418,23 @@ export class WorkersUcanService {
|
|
|
265
418
|
const cacheKey = `${userDid}:${opts.resource}:${opts.requiredAbility}`;
|
|
266
419
|
const cached = this.storeDelegationCache.get(cacheKey);
|
|
267
420
|
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
|
421
|
+
const pending = this.storeDelegationInFlight.get(cacheKey);
|
|
422
|
+
if (pending) return pending;
|
|
423
|
+
const lookup = this.fetchServiceDelegation(userDid, opts, cacheKey).finally(
|
|
424
|
+
() => {
|
|
425
|
+
if (this.storeDelegationInFlight.get(cacheKey) === lookup)
|
|
426
|
+
this.storeDelegationInFlight.delete(cacheKey);
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
this.storeDelegationInFlight.set(cacheKey, lookup);
|
|
430
|
+
return lookup;
|
|
431
|
+
}
|
|
268
432
|
|
|
433
|
+
private async fetchServiceDelegation(
|
|
434
|
+
userDid: string,
|
|
435
|
+
opts: { storeUrl: string; resource: string; requiredAbility: string },
|
|
436
|
+
cacheKey: string,
|
|
437
|
+
): Promise<ServiceDelegationResult> {
|
|
269
438
|
const inv = await this.mintSelfSignedInvocation(
|
|
270
439
|
opts.storeUrl,
|
|
271
440
|
{ can: 'store/get', with: 'ixo:ucan-store' },
|
|
@@ -288,6 +457,8 @@ export class WorkersUcanService {
|
|
|
288
457
|
authorization: `Bearer ${inv.invocation}`,
|
|
289
458
|
'x-auth-type': 'ucan',
|
|
290
459
|
},
|
|
460
|
+
// Every caller waiting on this look-up waits on this request.
|
|
461
|
+
signal: this.fetchSignal(),
|
|
291
462
|
},
|
|
292
463
|
);
|
|
293
464
|
} catch (error) {
|
|
@@ -313,9 +484,13 @@ export class WorkersUcanService {
|
|
|
313
484
|
for (const row of body.delegations ?? []) {
|
|
314
485
|
if (row.lifecycleState !== 'active') continue;
|
|
315
486
|
if (row.expiresAt != null && row.expiresAt <= nowSeconds) continue;
|
|
487
|
+
// The stored grant must cover what was asked for: the same resource
|
|
488
|
+
// or a parent of it (never a sibling sharing a prefix, never a
|
|
489
|
+
// narrower grant standing in for the broader resource), with an
|
|
490
|
+
// ability that covers the required one.
|
|
316
491
|
const match = (row.capabilities ?? []).find(
|
|
317
492
|
(c) =>
|
|
318
|
-
(c.with
|
|
493
|
+
resourceCovers(c.with, opts.resource) &&
|
|
319
494
|
abilityCovers(c.can, opts.requiredAbility),
|
|
320
495
|
);
|
|
321
496
|
if (!match) continue;
|
|
@@ -341,13 +516,8 @@ export class WorkersUcanService {
|
|
|
341
516
|
userDid: string,
|
|
342
517
|
delegation: UcanDelegation | undefined,
|
|
343
518
|
): RuntimeContext['ucan'] {
|
|
344
|
-
const caps = delegation?.capabilities ?? [];
|
|
345
519
|
const has = (resource: string, action: string) =>
|
|
346
|
-
|
|
347
|
-
(c) =>
|
|
348
|
-
(c.resource === resource || c.resource.startsWith(resource)) &&
|
|
349
|
-
abilityCovers(c.action, action),
|
|
350
|
-
);
|
|
520
|
+
delegationHasCapability(delegation, resource, action);
|
|
351
521
|
return {
|
|
352
522
|
hasCapability: has,
|
|
353
523
|
requireCapability: (resource, action) => {
|