@ixo/oracle-runtime-workers 0.13.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 +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 +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -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 +2003 -274
- 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 +562 -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/shell/app.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authenticateChannel,
|
|
3
|
+
assertActiveChannelBinding,
|
|
4
|
+
channelAuthConfig,
|
|
5
|
+
} from '../channels/auth';
|
|
6
|
+
import {
|
|
7
|
+
ChannelError,
|
|
8
|
+
ChannelTurnBody,
|
|
9
|
+
channelRequestHash,
|
|
10
|
+
readChannelBody,
|
|
11
|
+
} from '../channels/contract';
|
|
1
12
|
/* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
|
|
2
13
|
/**
|
|
3
14
|
* The HTTP shell — a Hono app that speaks the same wire protocol as the Node
|
|
@@ -6,8 +17,18 @@
|
|
|
6
17
|
* work unchanged. Every authenticated route resolves the caller's
|
|
7
18
|
* `UserOracleDO` and forwards to it; streaming turns are proxied byte-for-byte.
|
|
8
19
|
*/
|
|
20
|
+
import { FRONTEND_BRIDGE } from '@ixo/common/ai/frontend-bridge';
|
|
21
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
22
|
+
import { HTTPException } from 'hono/http-exception';
|
|
23
|
+
import {
|
|
24
|
+
TopicOperationId,
|
|
25
|
+
TopicDeliverableRequestSchema,
|
|
26
|
+
TOPIC_DELIVERABLE_BODY_BYTES,
|
|
27
|
+
type TopicDeliverableCommand,
|
|
28
|
+
} from '../tasks/topic-deliverables';
|
|
9
29
|
import { parseTranscriptPageQuery } from '../do/transcript';
|
|
10
30
|
import { Hono } from 'hono';
|
|
31
|
+
import { routePath } from 'hono/route';
|
|
11
32
|
import { cors } from 'hono/cors';
|
|
12
33
|
import type { OracleWorkerEnv, TurnIdentity } from '../do/contracts';
|
|
13
34
|
import { parseOwnerCopyFailure } from '../owner-store/owner-copy-errors';
|
|
@@ -22,12 +43,26 @@ import {
|
|
|
22
43
|
type DelegatedCapability,
|
|
23
44
|
} from '../do/ucan-service';
|
|
24
45
|
import {
|
|
46
|
+
authConfigFromEnv,
|
|
25
47
|
authenticate,
|
|
26
48
|
isExcluded,
|
|
49
|
+
validateDelegation,
|
|
27
50
|
type AuthResult,
|
|
28
51
|
type RouteExclusion,
|
|
29
52
|
} from './auth';
|
|
30
|
-
import {
|
|
53
|
+
import { MAX_TURN_BODY_BYTES } from './turn-body-cap';
|
|
54
|
+
import {
|
|
55
|
+
artifactDataResponse,
|
|
56
|
+
artifactPageResponse,
|
|
57
|
+
} from '../artifacts/routes';
|
|
58
|
+
import { ARTIFACT_ID_RE } from '../artifacts/store';
|
|
59
|
+
import { FEEDBACK_BODY_MAX_BYTES } from '../feedback/contract';
|
|
60
|
+
import {
|
|
61
|
+
feedbackFailure,
|
|
62
|
+
submitFeedback,
|
|
63
|
+
withFeedbackCapability,
|
|
64
|
+
type FeedbackShellOptions,
|
|
65
|
+
} from '../feedback/submit';
|
|
31
66
|
import { z } from 'zod';
|
|
32
67
|
|
|
33
68
|
const DebugEventBody = z.object({
|
|
@@ -54,15 +89,48 @@ export interface PluginRoute {
|
|
|
54
89
|
) => Response | Promise<Response>;
|
|
55
90
|
}
|
|
56
91
|
|
|
92
|
+
/** What `GET /models` hands its payload provider besides the env. */
|
|
93
|
+
export interface ListModelsOptions {
|
|
94
|
+
waitUntil?: (work: Promise<unknown>) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The request's `waitUntil`, or undefined when the app runs without an
|
|
99
|
+
* execution context (Hono's `executionCtx` getter throws then).
|
|
100
|
+
*/
|
|
101
|
+
function executionWaitUntil(c: {
|
|
102
|
+
readonly executionCtx: { waitUntil(work: Promise<unknown>): void };
|
|
103
|
+
}): ((work: Promise<unknown>) => void) | undefined {
|
|
104
|
+
let ctx: { waitUntil(work: Promise<unknown>): void };
|
|
105
|
+
try {
|
|
106
|
+
ctx = c.executionCtx;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return (work) => ctx.waitUntil(work);
|
|
111
|
+
}
|
|
112
|
+
|
|
57
113
|
export interface ShellOptions {
|
|
58
114
|
/** Routes contributed by plugins (`getRoutes`) and the host. */
|
|
59
115
|
routes?: PluginRoute[];
|
|
60
116
|
/** Auth exclusions contributed by plugins and the host. */
|
|
61
117
|
authExcludedRoutes?: RouteExclusion[];
|
|
62
|
-
/**
|
|
63
|
-
|
|
118
|
+
/**
|
|
119
|
+
* `GET /models` payload provider (may fetch live prices). `waitUntil`
|
|
120
|
+
* keeps work it starts in the background (a price refresh) alive past the
|
|
121
|
+
* response; absent when the request has no execution context.
|
|
122
|
+
*/
|
|
123
|
+
listModels?: (
|
|
124
|
+
env: OracleWorkerEnv,
|
|
125
|
+
options?: ListModelsOptions,
|
|
126
|
+
) => unknown | Promise<unknown>;
|
|
64
127
|
/** Version banner for `GET /`. */
|
|
65
128
|
banner?: { name: string; description?: string };
|
|
129
|
+
/**
|
|
130
|
+
* Anonymous Agent-response feedback (`src/feedback/`). Absent = off: the
|
|
131
|
+
* route answers 404 and transcripts do not advertise the capability.
|
|
132
|
+
*/
|
|
133
|
+
feedback?: FeedbackShellOptions | null;
|
|
66
134
|
}
|
|
67
135
|
|
|
68
136
|
type Variables = { auth: AuthResult };
|
|
@@ -71,6 +139,20 @@ function errorText(err: unknown): string {
|
|
|
71
139
|
return err instanceof Error ? err.message : String(err);
|
|
72
140
|
}
|
|
73
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
|
+
|
|
74
156
|
/** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
|
|
75
157
|
function isAbortRejection(err: unknown): boolean {
|
|
76
158
|
return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
|
|
@@ -78,18 +160,121 @@ function isAbortRejection(err: unknown): boolean {
|
|
|
78
160
|
);
|
|
79
161
|
}
|
|
80
162
|
|
|
81
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Routes that answer without a UCAN invocation: the banner, liveness
|
|
165
|
+
* probes, the model list, and the gateway's status/start that monitors and
|
|
166
|
+
* the drills poll. Every one of them is read-only or idempotent; nothing
|
|
167
|
+
* that changes state for other users is here (the debug routes included —
|
|
168
|
+
* they authenticate like every other route).
|
|
169
|
+
*/
|
|
170
|
+
export const BUILTIN_EXCLUSIONS: readonly RouteExclusion[] = [
|
|
82
171
|
{ path: '/', method: 'GET' },
|
|
83
172
|
{ path: '/health', method: 'ALL' },
|
|
84
173
|
{ path: '/health/*', method: 'ALL' },
|
|
85
174
|
{ path: '/models', method: 'GET' },
|
|
86
175
|
{ path: '/matrix/status', method: 'GET' },
|
|
87
176
|
{ path: '/matrix/start', method: 'POST' },
|
|
88
|
-
// Operator-only; the /debug/* middleware 404s unless ORACLE_DEBUG_ROUTES=true.
|
|
89
|
-
{ path: '/debug/matrix/restart', method: 'POST' },
|
|
90
|
-
{ path: '/debug/matrix/stop', method: 'POST' },
|
|
91
177
|
];
|
|
92
178
|
|
|
179
|
+
// Body caps of the built-in routes (a turn's is `MAX_TURN_BODY_BYTES`).
|
|
180
|
+
/** `POST /messages/abort`: a session id. */
|
|
181
|
+
export const ABORT_BODY_BYTES = 4 * 1024;
|
|
182
|
+
/** `POST /delegation`: one serialized delegation (a base64 CAR with its proof chain). */
|
|
183
|
+
export const DELEGATION_BODY_BYTES = 64 * 1024;
|
|
184
|
+
/** `/byo-llm/*`: provider credentials and OAuth exchanges. */
|
|
185
|
+
export const BYO_LLM_BODY_BYTES = 64 * 1024;
|
|
186
|
+
/** `/debug/*`: operator drills (the largest is one Matrix event, itself capped at 64 KiB). */
|
|
187
|
+
export const DEBUG_BODY_BYTES = 64 * 1024;
|
|
188
|
+
|
|
189
|
+
/** A body cap enforced before the body is buffered: over it, 413 and the handler never runs. */
|
|
190
|
+
function limitBody(maxSize: number) {
|
|
191
|
+
return bodyLimit({
|
|
192
|
+
maxSize,
|
|
193
|
+
onError: (c) =>
|
|
194
|
+
c.json({ statusCode: 413, message: 'request entity too large' }, 413),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const DID_SEGMENT = String.raw`(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+`;
|
|
199
|
+
/** A W3C DID (`did:<method>:<method-specific-id>`), bounded in length. */
|
|
200
|
+
const DID_RE = new RegExp(`^did:[a-z0-9]+:${DID_SEGMENT}(?::${DID_SEGMENT})*$`);
|
|
201
|
+
const MAX_DID_LENGTH = 512;
|
|
202
|
+
|
|
203
|
+
export function isDidShaped(value: string): boolean {
|
|
204
|
+
return value.length <= MAX_DID_LENGTH && DID_RE.test(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The key of one rate-limit budget. Prefixed with the oracle's DID: rate
|
|
209
|
+
* limiter bindings that share a `namespace_id` share their counters, even
|
|
210
|
+
* across Workers, so two oracles on one account would otherwise share every
|
|
211
|
+
* user's budget.
|
|
212
|
+
*/
|
|
213
|
+
export function rateLimitKey(
|
|
214
|
+
env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
|
|
215
|
+
scope: 'user' | 'artifact' | 'socket' | 'status' | 'start',
|
|
216
|
+
subject: string,
|
|
217
|
+
): string {
|
|
218
|
+
return `${env.ORACLE_DID}|${scope}|${subject}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Whether an unauthenticated caller (keyed by its IP) is over its budget.
|
|
223
|
+
* No binding or no client IP (local runs, tests): never limited.
|
|
224
|
+
*/
|
|
225
|
+
async function ipOverLimit(
|
|
226
|
+
env: OracleWorkerEnv,
|
|
227
|
+
scope: 'artifact' | 'socket' | 'status' | 'start',
|
|
228
|
+
ip: string | undefined,
|
|
229
|
+
): Promise<boolean> {
|
|
230
|
+
if (!ip || !env.RATE_LIMIT) return false;
|
|
231
|
+
const { success } = await env.RATE_LIMIT.limit({
|
|
232
|
+
key: rateLimitKey(env, scope, ip),
|
|
233
|
+
});
|
|
234
|
+
return !success;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The operators allowed onto the gateway-wide debug routes
|
|
239
|
+
* (`ORACLE_OPERATOR_DIDS`, comma-separated DIDs): null when the variable is
|
|
240
|
+
* unset, an empty set when it is malformed (fail closed — nobody, and an
|
|
241
|
+
* error in the log). Parsed once per distinct value.
|
|
242
|
+
*/
|
|
243
|
+
let operatorList: { raw: string; dids: ReadonlySet<string> } | null = null;
|
|
244
|
+
|
|
245
|
+
function operatorDids(env: OracleWorkerEnv): ReadonlySet<string> | null {
|
|
246
|
+
const raw = env.ORACLE_OPERATOR_DIDS;
|
|
247
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
248
|
+
if (operatorList?.raw === raw) return operatorList.dids;
|
|
249
|
+
const entries = raw
|
|
250
|
+
.split(',')
|
|
251
|
+
.map((did) => did.trim())
|
|
252
|
+
.filter(Boolean);
|
|
253
|
+
const malformed = entries.filter((did) => !isDidShaped(did));
|
|
254
|
+
if (malformed.length > 0)
|
|
255
|
+
console.error(
|
|
256
|
+
`[shell] ORACLE_OPERATOR_DIDS has entries that are not DIDs (${malformed.join(', ')}); no caller is let onto /debug/matrix/*`,
|
|
257
|
+
);
|
|
258
|
+
operatorList = {
|
|
259
|
+
raw,
|
|
260
|
+
dids: new Set(malformed.length > 0 ? [] : entries),
|
|
261
|
+
};
|
|
262
|
+
return operatorList.dids;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** What `GET /health/matrix` answers: the gateway's in-memory `running` flag. */
|
|
266
|
+
async function gatewayRunning(env: OracleWorkerEnv): Promise<boolean> {
|
|
267
|
+
const res = await gateway(env).fetch('https://matrix-gateway/health');
|
|
268
|
+
if (!res.ok) return false;
|
|
269
|
+
const body: unknown = await res.json();
|
|
270
|
+
return (
|
|
271
|
+
typeof body === 'object' &&
|
|
272
|
+
body !== null &&
|
|
273
|
+
'running' in body &&
|
|
274
|
+
body.running === true
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
93
278
|
export function createShell(
|
|
94
279
|
opts: ShellOptions = {},
|
|
95
280
|
): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
|
|
@@ -106,6 +291,9 @@ export function createShell(
|
|
|
106
291
|
// — the shell only routes on `?userDid`, and the object refuses a token
|
|
107
292
|
// that does not belong to that DID. The 101 response must also reach the
|
|
108
293
|
// client untouched (no CORS header rewriting on an upgraded response).
|
|
294
|
+
// Unauthenticated until CONNECT, so before any object is addressed the
|
|
295
|
+
// DID must at least look like one and the caller's IP must be within its
|
|
296
|
+
// budget: each upgrade costs a user object a request and a held socket.
|
|
109
297
|
app.all('/socket.io/*', async (c) => {
|
|
110
298
|
const userDid = c.req.query('userDid');
|
|
111
299
|
if (!userDid) {
|
|
@@ -114,6 +302,12 @@ export function createShell(
|
|
|
114
302
|
400,
|
|
115
303
|
);
|
|
116
304
|
}
|
|
305
|
+
if (!isDidShaped(userDid)) {
|
|
306
|
+
return c.json(
|
|
307
|
+
{ code: 3, message: 'userDid query parameter is not a DID.' },
|
|
308
|
+
400,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
117
311
|
if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
|
|
118
312
|
return c.json(
|
|
119
313
|
{
|
|
@@ -124,6 +318,8 @@ export function createShell(
|
|
|
124
318
|
426,
|
|
125
319
|
);
|
|
126
320
|
}
|
|
321
|
+
if (await ipOverLimit(c.env, 'socket', c.req.header('cf-connecting-ip')))
|
|
322
|
+
return c.json({ code: 3, message: 'Too many requests.' }, 429);
|
|
127
323
|
const target = new URL(c.req.url);
|
|
128
324
|
target.protocol = 'https:';
|
|
129
325
|
target.host = 'user-oracle';
|
|
@@ -132,6 +328,22 @@ export function createShell(
|
|
|
132
328
|
return userStub(c.env, userDid).fetch(forwarded);
|
|
133
329
|
});
|
|
134
330
|
|
|
331
|
+
// --- artefact links (public) -----------------------------------------------
|
|
332
|
+
// Also ahead of CORS and auth: a link is opened by whoever holds it, and
|
|
333
|
+
// the data route answers any origin itself (a shared viewer on another
|
|
334
|
+
// host fetches it). Both serve only what is safe without the key, which
|
|
335
|
+
// stays in the link's fragment: a static page and ciphertext.
|
|
336
|
+
app.get('/a/:artifactId', (c) =>
|
|
337
|
+
artifactPageResponse(c.env, c.req.param('artifactId')),
|
|
338
|
+
);
|
|
339
|
+
app.get('/a/:artifactId/data', async (c) => {
|
|
340
|
+
if (await ipOverLimit(c.env, 'artifact', c.req.header('cf-connecting-ip')))
|
|
341
|
+
return c.text('Too many requests', 429, {
|
|
342
|
+
'access-control-allow-origin': '*',
|
|
343
|
+
});
|
|
344
|
+
return artifactDataResponse(c.env, c.req.param('artifactId'));
|
|
345
|
+
});
|
|
346
|
+
|
|
135
347
|
app.use('*', async (c, next) => {
|
|
136
348
|
const origin = c.env.CORS_ORIGIN ?? '*';
|
|
137
349
|
return cors({
|
|
@@ -161,43 +373,137 @@ export function createShell(
|
|
|
161
373
|
timestamp: new Date().toISOString(),
|
|
162
374
|
}),
|
|
163
375
|
);
|
|
376
|
+
// `frontendTools` is the frontend bridge version: the Portal enables
|
|
377
|
+
// conversational writes only against a runtime that advertises it. A
|
|
378
|
+
// version signal, not proof the deployment behaves (see frontend-bridge.md).
|
|
164
379
|
app.get('/health', (c) =>
|
|
165
|
-
c.json({
|
|
380
|
+
c.json({
|
|
381
|
+
status: 'ok',
|
|
382
|
+
timestamp: new Date().toISOString(),
|
|
383
|
+
frontendTools: FRONTEND_BRIDGE,
|
|
384
|
+
}),
|
|
166
385
|
);
|
|
386
|
+
// A liveness probe: the gateway's in-memory flag, no storage read. The
|
|
387
|
+
// full picture is `/matrix/status`.
|
|
167
388
|
app.get('/health/matrix', async (c) => {
|
|
168
|
-
const
|
|
169
|
-
|
|
389
|
+
const running = await gatewayRunning(c.env).catch((err: unknown) => {
|
|
390
|
+
console.warn(`[shell] gateway health check failed: ${errorText(err)}`);
|
|
391
|
+
return false;
|
|
392
|
+
});
|
|
393
|
+
return c.json({ running }, running ? 200 : 503);
|
|
170
394
|
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
c.json(
|
|
177
|
-
|
|
395
|
+
// Public (monitors and the drills poll it), so limited per IP; the
|
|
396
|
+
// gateway serves the storage-scanning part from a short cache.
|
|
397
|
+
app.get('/matrix/status', async (c) => {
|
|
398
|
+
if (await ipOverLimit(c.env, 'status', c.req.header('cf-connecting-ip')))
|
|
399
|
+
return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
|
|
400
|
+
return c.json(await gateway(c.env).status());
|
|
401
|
+
});
|
|
402
|
+
app.post('/matrix/start', async (c) => {
|
|
403
|
+
if (await ipOverLimit(c.env, 'start', c.req.header('cf-connecting-ip')))
|
|
404
|
+
return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
|
|
405
|
+
return c.json(await gateway(c.env).ensureStarted());
|
|
406
|
+
});
|
|
407
|
+
app.get('/models', async (c) => {
|
|
408
|
+
const waitUntil = executionWaitUntil(c);
|
|
409
|
+
return c.json(
|
|
410
|
+
(await opts.listModels?.(c.env, waitUntil ? { waitUntil } : {})) ?? {
|
|
178
411
|
models: [],
|
|
179
412
|
// Node's `ModelListing` shape (`{ models, default }`); the client SDK
|
|
180
413
|
// reads `default`.
|
|
181
414
|
default: c.env.DEFAULT_MODEL ?? null,
|
|
182
415
|
},
|
|
183
|
-
)
|
|
184
|
-
);
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
app.post('/channels/turn', async (c) => {
|
|
420
|
+
try {
|
|
421
|
+
if (!c.env.CHANNEL_SERVICE_DID)
|
|
422
|
+
throw new ChannelError(
|
|
423
|
+
503,
|
|
424
|
+
'Channels are not configured',
|
|
425
|
+
'not_configured',
|
|
426
|
+
);
|
|
427
|
+
const raw = await readChannelBody(c.req.raw);
|
|
428
|
+
let json: unknown;
|
|
429
|
+
try {
|
|
430
|
+
json = JSON.parse(raw);
|
|
431
|
+
} catch {
|
|
432
|
+
throw new ChannelError(400, 'Invalid JSON request', 'invalid_request');
|
|
433
|
+
}
|
|
434
|
+
const parsed = ChannelTurnBody.safeParse(json);
|
|
435
|
+
if (!parsed.success)
|
|
436
|
+
throw new ChannelError(400, 'Invalid channel turn', 'invalid_request');
|
|
437
|
+
const identity = await authenticateChannel(
|
|
438
|
+
c.req.raw.headers,
|
|
439
|
+
raw,
|
|
440
|
+
parsed.data,
|
|
441
|
+
channelAuthConfig(c.env),
|
|
442
|
+
);
|
|
443
|
+
// Limited as soon as the user is proven, before the Auth Hub round
|
|
444
|
+
// trip: a flood or a tight poll must not cost one call there each.
|
|
445
|
+
if (
|
|
446
|
+
c.env.RATE_LIMIT &&
|
|
447
|
+
!(
|
|
448
|
+
await c.env.RATE_LIMIT.limit({
|
|
449
|
+
key: rateLimitKey(c.env, 'user', identity.userDid),
|
|
450
|
+
})
|
|
451
|
+
).success
|
|
452
|
+
)
|
|
453
|
+
throw new ChannelError(429, 'Too many requests', 'rate_limited');
|
|
454
|
+
await assertActiveChannelBinding(identity, c.env);
|
|
455
|
+
const outcome = await userStub(c.env, identity.userDid).channelTurn(
|
|
456
|
+
identity,
|
|
457
|
+
parsed.data,
|
|
458
|
+
await channelRequestHash(raw),
|
|
459
|
+
);
|
|
460
|
+
if (!outcome.ok)
|
|
461
|
+
return c.json(
|
|
462
|
+
{ code: outcome.code, message: outcome.message },
|
|
463
|
+
outcome.status,
|
|
464
|
+
);
|
|
465
|
+
const result = outcome.result;
|
|
466
|
+
return c.json(
|
|
467
|
+
result,
|
|
468
|
+
['queued', 'running', 'recovering'].includes(result.status) ? 202 : 200,
|
|
469
|
+
);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (error instanceof ChannelError)
|
|
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
|
+
);
|
|
481
|
+
return c.json(
|
|
482
|
+
{
|
|
483
|
+
code: 'unavailable',
|
|
484
|
+
message: 'Channel turn is temporarily unavailable',
|
|
485
|
+
},
|
|
486
|
+
503,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
185
490
|
|
|
186
491
|
// --- auth ------------------------------------------------------------------
|
|
187
492
|
app.use('*', async (c, next) => {
|
|
188
493
|
if (isExcluded(c.req.method, c.req.path, exclusions)) return next();
|
|
189
|
-
const outcome = await authenticate(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
? Number(c.env.UCAN_AUTH_MAX_TTL_SECONDS)
|
|
194
|
-
: undefined,
|
|
195
|
-
});
|
|
494
|
+
const outcome = await authenticate(
|
|
495
|
+
c.req.raw.headers,
|
|
496
|
+
authConfigFromEnv(c.env),
|
|
497
|
+
);
|
|
196
498
|
if (!outcome.ok)
|
|
197
499
|
return c.json(
|
|
198
500
|
{ statusCode: outcome.status, message: outcome.error },
|
|
199
501
|
outcome.status as 401,
|
|
200
502
|
);
|
|
503
|
+
if (outcome.auth.via === 'delegation')
|
|
504
|
+
console.warn(
|
|
505
|
+
`[auth] ${c.req.method} ${loggedRoute(c)}: ${outcome.auth.userDid} authenticated with a bare delegation (UCAN_ALLOW_BARE_DELEGATION_AUTH); the client must send a UCAN invocation before the fallback is turned off`,
|
|
506
|
+
);
|
|
201
507
|
c.set('auth', outcome.auth);
|
|
202
508
|
return next();
|
|
203
509
|
});
|
|
@@ -206,15 +512,17 @@ export function createShell(
|
|
|
206
512
|
// Cloudflare's native limiter; the limit/period live on the binding in
|
|
207
513
|
// wrangler.jsonc. Keyed by DID rather than IP so co-located users (one
|
|
208
514
|
// NAT/proxy) never share a bucket and a single leaked token can't burn
|
|
209
|
-
// unbounded LLM spend
|
|
210
|
-
// ran `next()` above
|
|
211
|
-
// (local harness / tests).
|
|
515
|
+
// unbounded LLM spend; the key carries the oracle's DID (`rateLimitKey`).
|
|
516
|
+
// Auth-excluded routes (health/models/matrix status) ran `next()` above
|
|
517
|
+
// and never reach here. Absent binding = no limiting (local harness / tests).
|
|
212
518
|
app.use('*', async (c, next) => {
|
|
213
519
|
const limiter = c.env.RATE_LIMIT;
|
|
214
520
|
if (!limiter) return next();
|
|
215
521
|
const auth = c.get('auth') as AuthResult | undefined;
|
|
216
522
|
if (!auth) return next();
|
|
217
|
-
const { success } = await limiter.limit({
|
|
523
|
+
const { success } = await limiter.limit({
|
|
524
|
+
key: rateLimitKey(c.env, 'user', auth.userDid),
|
|
525
|
+
});
|
|
218
526
|
if (!success)
|
|
219
527
|
return c.json(
|
|
220
528
|
{
|
|
@@ -261,8 +569,89 @@ export function createShell(
|
|
|
261
569
|
: c.json({ message: 'Session not found' }, 404);
|
|
262
570
|
});
|
|
263
571
|
|
|
572
|
+
app.use('/topic-deliverables/*', async (c, next) => {
|
|
573
|
+
if (c.env.TOPIC_DELIVERABLES_ENABLED !== 'true')
|
|
574
|
+
return c.json({ message: 'Not found.' }, 404);
|
|
575
|
+
if (c.get('auth')?.via !== 'invocation')
|
|
576
|
+
return c.json({ message: 'A signed invocation is required.' }, 401);
|
|
577
|
+
return next();
|
|
578
|
+
});
|
|
579
|
+
app.use(
|
|
580
|
+
'/topic-deliverables/*',
|
|
581
|
+
bodyLimit({
|
|
582
|
+
maxSize: TOPIC_DELIVERABLE_BODY_BYTES,
|
|
583
|
+
onError: (c) => c.json({ message: 'Request is too large.' }, 413),
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
app.on(
|
|
587
|
+
['PUT', 'GET', 'POST'],
|
|
588
|
+
[
|
|
589
|
+
'/topic-deliverables/:operationId',
|
|
590
|
+
'/topic-deliverables/:operationId/cancel',
|
|
591
|
+
],
|
|
592
|
+
async (c) => {
|
|
593
|
+
const cancelling =
|
|
594
|
+
c.req.routePath === '/topic-deliverables/:operationId/cancel';
|
|
595
|
+
if (cancelling !== (c.req.method === 'POST'))
|
|
596
|
+
return c.json({ message: 'Not found.' }, 404);
|
|
597
|
+
const operationId = TopicOperationId.safeParse(
|
|
598
|
+
c.req.param('operationId'),
|
|
599
|
+
);
|
|
600
|
+
if (!operationId.success)
|
|
601
|
+
return c.json({ message: 'Invalid operation ID.' }, 400);
|
|
602
|
+
let command: TopicDeliverableCommand;
|
|
603
|
+
if (c.req.method === 'PUT' || cancelling) {
|
|
604
|
+
const parsed = TopicDeliverableRequestSchema.safeParse(
|
|
605
|
+
await c.req.json().catch(() => null),
|
|
606
|
+
);
|
|
607
|
+
if (!parsed.success)
|
|
608
|
+
return c.json({ message: 'Invalid deliverable request.' }, 400);
|
|
609
|
+
command = {
|
|
610
|
+
action: cancelling ? 'cancel' : 'start',
|
|
611
|
+
request: parsed.data,
|
|
612
|
+
};
|
|
613
|
+
} else {
|
|
614
|
+
command = { action: 'read' };
|
|
615
|
+
}
|
|
616
|
+
const auth = c.get('auth');
|
|
617
|
+
const result = await userStub(c.env, auth.userDid).topicDeliverable(
|
|
618
|
+
identityOf(auth, c.req.raw.headers),
|
|
619
|
+
operationId.data,
|
|
620
|
+
command,
|
|
621
|
+
);
|
|
622
|
+
return result.ok
|
|
623
|
+
? c.json(result.snapshot)
|
|
624
|
+
: c.json({ message: result.message }, result.status);
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
// --- artefacts (owner) ---------------------------------------------------------
|
|
629
|
+
app.get('/artifacts/:artifactId', async (c) => {
|
|
630
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
631
|
+
const artifactId = c.req.param('artifactId');
|
|
632
|
+
const artifact = ARTIFACT_ID_RE.test(artifactId)
|
|
633
|
+
? await userStub(c.env, identity.userDid).artifact(identity, artifactId)
|
|
634
|
+
: null;
|
|
635
|
+
return artifact
|
|
636
|
+
? c.json(artifact)
|
|
637
|
+
: c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
|
|
638
|
+
});
|
|
639
|
+
app.delete('/artifacts/:artifactId', async (c) => {
|
|
640
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
641
|
+
const artifactId = c.req.param('artifactId');
|
|
642
|
+
const revoked =
|
|
643
|
+
ARTIFACT_ID_RE.test(artifactId) &&
|
|
644
|
+
(await userStub(c.env, identity.userDid).revokeArtifact(
|
|
645
|
+
identity,
|
|
646
|
+
artifactId,
|
|
647
|
+
));
|
|
648
|
+
return revoked
|
|
649
|
+
? c.json({ revoked: true })
|
|
650
|
+
: c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
|
|
651
|
+
});
|
|
652
|
+
|
|
264
653
|
// --- messages ----------------------------------------------------------------
|
|
265
|
-
app.post('/messages/abort', async (c) => {
|
|
654
|
+
app.post('/messages/abort', limitBody(ABORT_BODY_BYTES), async (c) => {
|
|
266
655
|
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
267
656
|
const body = (await c.req.json().catch(() => ({}))) as {
|
|
268
657
|
sessionId?: string;
|
|
@@ -280,10 +669,43 @@ export function createShell(
|
|
|
280
669
|
identity,
|
|
281
670
|
c.req.param('sessionId'),
|
|
282
671
|
);
|
|
283
|
-
return new Response(
|
|
284
|
-
|
|
285
|
-
|
|
672
|
+
return new Response(
|
|
673
|
+
withFeedbackCapability(`{"messages":${messages}}`, !!opts.feedback),
|
|
674
|
+
{ headers: { 'content-type': 'application/json' } },
|
|
675
|
+
);
|
|
286
676
|
});
|
|
677
|
+
// Anonymous feedback on one completed Agent reply (src/feedback/submit.ts).
|
|
678
|
+
app.post(
|
|
679
|
+
'/messages/:sessionId/:messageId/feedback',
|
|
680
|
+
bodyLimit({
|
|
681
|
+
maxSize: FEEDBACK_BODY_MAX_BYTES,
|
|
682
|
+
onError: (c) =>
|
|
683
|
+
c.json({ statusCode: 413, message: 'request entity too large' }, 413),
|
|
684
|
+
}),
|
|
685
|
+
async (c) => {
|
|
686
|
+
if (!opts.feedback) {
|
|
687
|
+
const off = feedbackFailure(
|
|
688
|
+
404,
|
|
689
|
+
'FEEDBACK_DISABLED',
|
|
690
|
+
'Anonymous feedback is not enabled on this oracle',
|
|
691
|
+
);
|
|
692
|
+
return c.json(off.body, off.status);
|
|
693
|
+
}
|
|
694
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
695
|
+
const result = await submitFeedback({
|
|
696
|
+
options: opts.feedback,
|
|
697
|
+
user: userStub(c.env, identity.userDid),
|
|
698
|
+
identity,
|
|
699
|
+
sessionId: c.req.param('sessionId'),
|
|
700
|
+
messageId: c.req.param('messageId'),
|
|
701
|
+
body: await c.req.json<unknown>().catch(() => undefined),
|
|
702
|
+
clientIp: c.req.header('cf-connecting-ip'),
|
|
703
|
+
ipLimiter: c.env.FEEDBACK_RATE_LIMIT ?? c.env.RATE_LIMIT,
|
|
704
|
+
log: console,
|
|
705
|
+
});
|
|
706
|
+
return c.json(result.body, result.status);
|
|
707
|
+
},
|
|
708
|
+
);
|
|
287
709
|
// One turn-aligned page of the transcript (docs/plans/transcript-paging.md):
|
|
288
710
|
// the newest `limit` turns, the turns `before=` a cursor, or what came
|
|
289
711
|
// `after=` one. The legacy listing above stays the whole transcript.
|
|
@@ -299,7 +721,7 @@ export function createShell(
|
|
|
299
721
|
);
|
|
300
722
|
if (!page.ok)
|
|
301
723
|
return c.json({ statusCode: page.status, message: page.message }, 400);
|
|
302
|
-
return new Response(page.json, {
|
|
724
|
+
return new Response(withFeedbackCapability(page.json, !!opts.feedback), {
|
|
303
725
|
headers: { 'content-type': 'application/json' },
|
|
304
726
|
});
|
|
305
727
|
});
|
|
@@ -332,53 +754,64 @@ export function createShell(
|
|
|
332
754
|
);
|
|
333
755
|
return new Response(res.body, { status: res.status, headers });
|
|
334
756
|
});
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
757
|
+
// The cap bounds the memory one request can pin (see turn-body-cap.ts); it
|
|
758
|
+
// is enforced before the body is read, with the status and message Node's
|
|
759
|
+
// body parser uses.
|
|
760
|
+
app.post(
|
|
761
|
+
'/messages/:sessionId',
|
|
762
|
+
limitBody(MAX_TURN_BODY_BYTES),
|
|
763
|
+
async (c) => {
|
|
764
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
765
|
+
const sessionId = c.req.param('sessionId');
|
|
766
|
+
const body = await c.req.text();
|
|
767
|
+
// Node accepts a client-supplied `requestId` (correlates the SSE events
|
|
768
|
+
// with the client's own bookkeeping); fall back to a fresh UUID.
|
|
769
|
+
const requestId = clientRequestId(body) ?? crypto.randomUUID();
|
|
770
|
+
// The user object owns the turn; it returns either an SSE stream or JSON.
|
|
771
|
+
// We pass the raw body through so attachments/tools/agActions survive.
|
|
772
|
+
const res = await userStub(c.env, identity.userDid).fetch(
|
|
773
|
+
`https://user-oracle/turn/${encodeURIComponent(sessionId)}`,
|
|
774
|
+
{
|
|
775
|
+
method: 'POST',
|
|
776
|
+
headers: {
|
|
777
|
+
'content-type': 'application/json',
|
|
778
|
+
'x-request-id': requestId,
|
|
779
|
+
'x-identity': JSON.stringify(identity),
|
|
780
|
+
},
|
|
781
|
+
body,
|
|
360
782
|
},
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
});
|
|
783
|
+
);
|
|
784
|
+
const headers = new Headers(res.headers);
|
|
785
|
+
headers.set('x-request-id', requestId);
|
|
786
|
+
headers.set('access-control-expose-headers', 'x-request-id');
|
|
787
|
+
return new Response(res.body, { status: res.status, headers });
|
|
788
|
+
},
|
|
789
|
+
);
|
|
369
790
|
|
|
370
791
|
// --- delegation (room-state persisted, for header-less Matrix turns) -----------
|
|
371
|
-
app.post('/delegation', async (c) => {
|
|
792
|
+
app.post('/delegation', limitBody(DELEGATION_BODY_BYTES), async (c) => {
|
|
372
793
|
const auth = c.get('auth');
|
|
373
|
-
const body = (await c.req.json().catch(() => ({}))) as {
|
|
374
|
-
raw?: string;
|
|
375
|
-
expiration?: number;
|
|
376
|
-
};
|
|
794
|
+
const body = (await c.req.json().catch(() => ({}))) as { raw?: string };
|
|
377
795
|
const raw = body.raw ?? auth.delegation;
|
|
378
796
|
if (!raw) return c.json({ message: 'raw delegation is required' }, 400);
|
|
797
|
+
// What is stored here authorizes header-less turns long after this
|
|
798
|
+
// request, so it gets the header's checks: for this oracle, issued by
|
|
799
|
+
// the caller, with a bounded expiry — and that expiry is the token's,
|
|
800
|
+
// never a value the client states beside it.
|
|
801
|
+
const checked = await validateDelegation(raw, authConfigFromEnv(c.env));
|
|
802
|
+
if (!checked.ok)
|
|
803
|
+
return c.json(
|
|
804
|
+
{ message: `Invalid UCAN delegation: ${checked.error}` },
|
|
805
|
+
400,
|
|
806
|
+
);
|
|
807
|
+
if (checked.userDid !== auth.userDid)
|
|
808
|
+
return c.json(
|
|
809
|
+
{ message: 'The delegation was not issued by the authenticated user' },
|
|
810
|
+
403,
|
|
811
|
+
);
|
|
379
812
|
const room = await gateway(c.env).resolveUserRoom(auth.userDid);
|
|
380
813
|
if (!room) return c.json({ message: 'No oracle room for this user' }, 404);
|
|
381
|
-
const expiration =
|
|
814
|
+
const { expiration } = checked;
|
|
382
815
|
// The Node runtime's `DelegationStore` record, in its compressed
|
|
383
816
|
// room-state envelope, so a user can move between runtimes without
|
|
384
817
|
// re-authorising (`updatedAt` is required by Node's schema).
|
|
@@ -390,7 +823,7 @@ export function createShell(
|
|
|
390
823
|
raw,
|
|
391
824
|
issuer: auth.userDid,
|
|
392
825
|
audience: c.env.ORACLE_DID,
|
|
393
|
-
|
|
826
|
+
expiration,
|
|
394
827
|
updatedAt: new Date().toISOString(),
|
|
395
828
|
}),
|
|
396
829
|
),
|
|
@@ -406,10 +839,7 @@ export function createShell(
|
|
|
406
839
|
`[shell] could not push the deposited delegation to ${auth.userDid}'s object: ${err instanceof Error ? err.message : String(err)}`,
|
|
407
840
|
);
|
|
408
841
|
});
|
|
409
|
-
return c.json({
|
|
410
|
-
ok: true,
|
|
411
|
-
expiration: body.expiration ?? auth.delegationExpiration,
|
|
412
|
-
});
|
|
842
|
+
return c.json({ ok: true, expiration });
|
|
413
843
|
});
|
|
414
844
|
app.get('/delegation', async (c) => {
|
|
415
845
|
const auth = c.get('auth');
|
|
@@ -477,6 +907,7 @@ export function createShell(
|
|
|
477
907
|
// so every route is proxied there — same pattern as streaming turns. The
|
|
478
908
|
// shell only short-circuits the disabled case so stray requests never boot
|
|
479
909
|
// a Durable Object.
|
|
910
|
+
app.use('/byo-llm/*', limitBody(BYO_LLM_BODY_BYTES));
|
|
480
911
|
app.all('/byo-llm/*', async (c) => {
|
|
481
912
|
if (c.env.BYO_LLM_ENABLED !== 'true') {
|
|
482
913
|
return c.req.method === 'GET' && c.req.path === '/byo-llm/status'
|
|
@@ -514,6 +945,20 @@ export function createShell(
|
|
|
514
945
|
return c.json({ statusCode: 404, message: 'Not found' }, 404);
|
|
515
946
|
return next();
|
|
516
947
|
});
|
|
948
|
+
app.use('/debug/*', limitBody(DEBUG_BODY_BYTES));
|
|
949
|
+
// The gateway-wide routes act on the one gateway every user depends on
|
|
950
|
+
// (stop, restart, reset, rotate, every user's outbox). With
|
|
951
|
+
// ORACLE_OPERATOR_DIDS set only those DIDs may call them; unset, any
|
|
952
|
+
// authenticated caller may (local and devnet drills).
|
|
953
|
+
app.use('/debug/matrix/*', async (c, next) => {
|
|
954
|
+
const operators = operatorDids(c.env);
|
|
955
|
+
if (operators && !operators.has(c.get('auth').userDid))
|
|
956
|
+
return c.json(
|
|
957
|
+
{ statusCode: 403, message: 'Operator routes are restricted' },
|
|
958
|
+
403,
|
|
959
|
+
);
|
|
960
|
+
return next();
|
|
961
|
+
});
|
|
517
962
|
app.get('/debug/storage', async (c) =>
|
|
518
963
|
c.json(await userStub(c.env, c.get('auth').userDid).storageStatus()),
|
|
519
964
|
);
|
|
@@ -533,7 +978,11 @@ export function createShell(
|
|
|
533
978
|
);
|
|
534
979
|
});
|
|
535
980
|
app.post('/debug/storage/reset', async (c) =>
|
|
536
|
-
c.json(
|
|
981
|
+
c.json(
|
|
982
|
+
await userStub(c.env, c.get('auth').userDid).resetWorkingCopy(
|
|
983
|
+
identityOf(c.get('auth'), c.req.raw.headers),
|
|
984
|
+
),
|
|
985
|
+
),
|
|
537
986
|
);
|
|
538
987
|
app.post('/debug/object/abort', async (c) => {
|
|
539
988
|
try {
|
|
@@ -665,7 +1114,15 @@ export function createShell(
|
|
|
665
1114
|
),
|
|
666
1115
|
);
|
|
667
1116
|
app.onError((err, c) => {
|
|
668
|
-
|
|
1117
|
+
// Thrown on purpose with a status and a message meant for the client.
|
|
1118
|
+
if (err instanceof HTTPException) return err.getResponse();
|
|
1119
|
+
if (err instanceof ChannelError)
|
|
1120
|
+
return c.json({ code: err.code, message: err.message }, err.status);
|
|
1121
|
+
const requestId = errorRequestId(c.req.header('x-request-id'));
|
|
1122
|
+
console.error(
|
|
1123
|
+
`[shell] ${c.req.method} ${loggedRoute(c)} failed (request ${requestId}):`,
|
|
1124
|
+
err,
|
|
1125
|
+
);
|
|
669
1126
|
// A cold boot that could not read the user's owner copy changed nothing.
|
|
670
1127
|
// Say precisely why: 503 + retryable for a transient store/VFS failure
|
|
671
1128
|
// (the object already retried with backoff), 403 for a missing
|
|
@@ -686,9 +1143,16 @@ export function createShell(
|
|
|
686
1143
|
failure.httpStatus,
|
|
687
1144
|
);
|
|
688
1145
|
}
|
|
1146
|
+
// Anything else may carry internals (SQL, homeserver or Blocksync error
|
|
1147
|
+
// text, room ids): the client gets a generic message and the id that
|
|
1148
|
+
// finds the details in the log.
|
|
689
1149
|
return c.json(
|
|
690
|
-
{ statusCode: 500, message:
|
|
1150
|
+
{ statusCode: 500, message: 'Internal server error', requestId },
|
|
691
1151
|
500,
|
|
1152
|
+
{
|
|
1153
|
+
'x-request-id': requestId,
|
|
1154
|
+
'access-control-expose-headers': 'x-request-id',
|
|
1155
|
+
},
|
|
692
1156
|
);
|
|
693
1157
|
});
|
|
694
1158
|
|
|
@@ -697,6 +1161,11 @@ export function createShell(
|
|
|
697
1161
|
|
|
698
1162
|
const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
699
1163
|
|
|
1164
|
+
/** The client's `x-request-id` when it is a sane opaque token, else a fresh one. */
|
|
1165
|
+
function errorRequestId(header: string | undefined): string {
|
|
1166
|
+
return header && REQUEST_ID_RE.test(header) ? header : crypto.randomUUID();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
700
1169
|
/** The body's `requestId` when it is a sane opaque token; null otherwise. */
|
|
701
1170
|
function clientRequestId(rawBody: string): string | null {
|
|
702
1171
|
try {
|
|
@@ -711,6 +1180,17 @@ function clientRequestId(rawBody: string): string | null {
|
|
|
711
1180
|
return null;
|
|
712
1181
|
}
|
|
713
1182
|
|
|
1183
|
+
/**
|
|
1184
|
+
* The route pattern a request matched (`/messages/:sessionId`), never the
|
|
1185
|
+
* concrete path: a path carries session and message ids, and a log line
|
|
1186
|
+
* that pairs them with the caller's DID could tie an anonymous feedback
|
|
1187
|
+
* submission to its author. In a middleware `c.req.routePath` is the
|
|
1188
|
+
* middleware's own `*`, so the handler's pattern (the last match) is used.
|
|
1189
|
+
*/
|
|
1190
|
+
function loggedRoute(c: Parameters<typeof routePath>[0]): string {
|
|
1191
|
+
return routePath(c, -1) || '(unmatched)';
|
|
1192
|
+
}
|
|
1193
|
+
|
|
714
1194
|
function identityOf(auth: AuthResult, headers: Headers): TurnIdentity {
|
|
715
1195
|
return {
|
|
716
1196
|
userDid: auth.userDid,
|