@ixo/oracle-runtime-workers 0.12.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 +14 -9
- 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 +198 -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 +523 -111
- 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/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 };
|
|
@@ -78,18 +146,121 @@ function isAbortRejection(err: unknown): boolean {
|
|
|
78
146
|
);
|
|
79
147
|
}
|
|
80
148
|
|
|
81
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Routes that answer without a UCAN invocation: the banner, liveness
|
|
151
|
+
* probes, the model list, and the gateway's status/start that monitors and
|
|
152
|
+
* the drills poll. Every one of them is read-only or idempotent; nothing
|
|
153
|
+
* that changes state for other users is here (the debug routes included —
|
|
154
|
+
* they authenticate like every other route).
|
|
155
|
+
*/
|
|
156
|
+
export const BUILTIN_EXCLUSIONS: readonly RouteExclusion[] = [
|
|
82
157
|
{ path: '/', method: 'GET' },
|
|
83
158
|
{ path: '/health', method: 'ALL' },
|
|
84
159
|
{ path: '/health/*', method: 'ALL' },
|
|
85
160
|
{ path: '/models', method: 'GET' },
|
|
86
161
|
{ path: '/matrix/status', method: 'GET' },
|
|
87
162
|
{ 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
163
|
];
|
|
92
164
|
|
|
165
|
+
// Body caps of the built-in routes (a turn's is `MAX_TURN_BODY_BYTES`).
|
|
166
|
+
/** `POST /messages/abort`: a session id. */
|
|
167
|
+
export const ABORT_BODY_BYTES = 4 * 1024;
|
|
168
|
+
/** `POST /delegation`: one serialized delegation (a base64 CAR with its proof chain). */
|
|
169
|
+
export const DELEGATION_BODY_BYTES = 64 * 1024;
|
|
170
|
+
/** `/byo-llm/*`: provider credentials and OAuth exchanges. */
|
|
171
|
+
export const BYO_LLM_BODY_BYTES = 64 * 1024;
|
|
172
|
+
/** `/debug/*`: operator drills (the largest is one Matrix event, itself capped at 64 KiB). */
|
|
173
|
+
export const DEBUG_BODY_BYTES = 64 * 1024;
|
|
174
|
+
|
|
175
|
+
/** A body cap enforced before the body is buffered: over it, 413 and the handler never runs. */
|
|
176
|
+
function limitBody(maxSize: number) {
|
|
177
|
+
return bodyLimit({
|
|
178
|
+
maxSize,
|
|
179
|
+
onError: (c) =>
|
|
180
|
+
c.json({ statusCode: 413, message: 'request entity too large' }, 413),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const DID_SEGMENT = String.raw`(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+`;
|
|
185
|
+
/** A W3C DID (`did:<method>:<method-specific-id>`), bounded in length. */
|
|
186
|
+
const DID_RE = new RegExp(`^did:[a-z0-9]+:${DID_SEGMENT}(?::${DID_SEGMENT})*$`);
|
|
187
|
+
const MAX_DID_LENGTH = 512;
|
|
188
|
+
|
|
189
|
+
export function isDidShaped(value: string): boolean {
|
|
190
|
+
return value.length <= MAX_DID_LENGTH && DID_RE.test(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The key of one rate-limit budget. Prefixed with the oracle's DID: rate
|
|
195
|
+
* limiter bindings that share a `namespace_id` share their counters, even
|
|
196
|
+
* across Workers, so two oracles on one account would otherwise share every
|
|
197
|
+
* user's budget.
|
|
198
|
+
*/
|
|
199
|
+
export function rateLimitKey(
|
|
200
|
+
env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
|
|
201
|
+
scope: 'user' | 'artifact' | 'socket' | 'status' | 'start',
|
|
202
|
+
subject: string,
|
|
203
|
+
): string {
|
|
204
|
+
return `${env.ORACLE_DID}|${scope}|${subject}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether an unauthenticated caller (keyed by its IP) is over its budget.
|
|
209
|
+
* No binding or no client IP (local runs, tests): never limited.
|
|
210
|
+
*/
|
|
211
|
+
async function ipOverLimit(
|
|
212
|
+
env: OracleWorkerEnv,
|
|
213
|
+
scope: 'artifact' | 'socket' | 'status' | 'start',
|
|
214
|
+
ip: string | undefined,
|
|
215
|
+
): Promise<boolean> {
|
|
216
|
+
if (!ip || !env.RATE_LIMIT) return false;
|
|
217
|
+
const { success } = await env.RATE_LIMIT.limit({
|
|
218
|
+
key: rateLimitKey(env, scope, ip),
|
|
219
|
+
});
|
|
220
|
+
return !success;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The operators allowed onto the gateway-wide debug routes
|
|
225
|
+
* (`ORACLE_OPERATOR_DIDS`, comma-separated DIDs): null when the variable is
|
|
226
|
+
* unset, an empty set when it is malformed (fail closed — nobody, and an
|
|
227
|
+
* error in the log). Parsed once per distinct value.
|
|
228
|
+
*/
|
|
229
|
+
let operatorList: { raw: string; dids: ReadonlySet<string> } | null = null;
|
|
230
|
+
|
|
231
|
+
function operatorDids(env: OracleWorkerEnv): ReadonlySet<string> | null {
|
|
232
|
+
const raw = env.ORACLE_OPERATOR_DIDS;
|
|
233
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
234
|
+
if (operatorList?.raw === raw) return operatorList.dids;
|
|
235
|
+
const entries = raw
|
|
236
|
+
.split(',')
|
|
237
|
+
.map((did) => did.trim())
|
|
238
|
+
.filter(Boolean);
|
|
239
|
+
const malformed = entries.filter((did) => !isDidShaped(did));
|
|
240
|
+
if (malformed.length > 0)
|
|
241
|
+
console.error(
|
|
242
|
+
`[shell] ORACLE_OPERATOR_DIDS has entries that are not DIDs (${malformed.join(', ')}); no caller is let onto /debug/matrix/*`,
|
|
243
|
+
);
|
|
244
|
+
operatorList = {
|
|
245
|
+
raw,
|
|
246
|
+
dids: new Set(malformed.length > 0 ? [] : entries),
|
|
247
|
+
};
|
|
248
|
+
return operatorList.dids;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** What `GET /health/matrix` answers: the gateway's in-memory `running` flag. */
|
|
252
|
+
async function gatewayRunning(env: OracleWorkerEnv): Promise<boolean> {
|
|
253
|
+
const res = await gateway(env).fetch('https://matrix-gateway/health');
|
|
254
|
+
if (!res.ok) return false;
|
|
255
|
+
const body: unknown = await res.json();
|
|
256
|
+
return (
|
|
257
|
+
typeof body === 'object' &&
|
|
258
|
+
body !== null &&
|
|
259
|
+
'running' in body &&
|
|
260
|
+
body.running === true
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
93
264
|
export function createShell(
|
|
94
265
|
opts: ShellOptions = {},
|
|
95
266
|
): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
|
|
@@ -106,6 +277,9 @@ export function createShell(
|
|
|
106
277
|
// — the shell only routes on `?userDid`, and the object refuses a token
|
|
107
278
|
// that does not belong to that DID. The 101 response must also reach the
|
|
108
279
|
// client untouched (no CORS header rewriting on an upgraded response).
|
|
280
|
+
// Unauthenticated until CONNECT, so before any object is addressed the
|
|
281
|
+
// DID must at least look like one and the caller's IP must be within its
|
|
282
|
+
// budget: each upgrade costs a user object a request and a held socket.
|
|
109
283
|
app.all('/socket.io/*', async (c) => {
|
|
110
284
|
const userDid = c.req.query('userDid');
|
|
111
285
|
if (!userDid) {
|
|
@@ -114,6 +288,12 @@ export function createShell(
|
|
|
114
288
|
400,
|
|
115
289
|
);
|
|
116
290
|
}
|
|
291
|
+
if (!isDidShaped(userDid)) {
|
|
292
|
+
return c.json(
|
|
293
|
+
{ code: 3, message: 'userDid query parameter is not a DID.' },
|
|
294
|
+
400,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
117
297
|
if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
|
|
118
298
|
return c.json(
|
|
119
299
|
{
|
|
@@ -124,6 +304,8 @@ export function createShell(
|
|
|
124
304
|
426,
|
|
125
305
|
);
|
|
126
306
|
}
|
|
307
|
+
if (await ipOverLimit(c.env, 'socket', c.req.header('cf-connecting-ip')))
|
|
308
|
+
return c.json({ code: 3, message: 'Too many requests.' }, 429);
|
|
127
309
|
const target = new URL(c.req.url);
|
|
128
310
|
target.protocol = 'https:';
|
|
129
311
|
target.host = 'user-oracle';
|
|
@@ -132,6 +314,22 @@ export function createShell(
|
|
|
132
314
|
return userStub(c.env, userDid).fetch(forwarded);
|
|
133
315
|
});
|
|
134
316
|
|
|
317
|
+
// --- artefact links (public) -----------------------------------------------
|
|
318
|
+
// Also ahead of CORS and auth: a link is opened by whoever holds it, and
|
|
319
|
+
// the data route answers any origin itself (a shared viewer on another
|
|
320
|
+
// host fetches it). Both serve only what is safe without the key, which
|
|
321
|
+
// stays in the link's fragment: a static page and ciphertext.
|
|
322
|
+
app.get('/a/:artifactId', (c) =>
|
|
323
|
+
artifactPageResponse(c.env, c.req.param('artifactId')),
|
|
324
|
+
);
|
|
325
|
+
app.get('/a/:artifactId/data', async (c) => {
|
|
326
|
+
if (await ipOverLimit(c.env, 'artifact', c.req.header('cf-connecting-ip')))
|
|
327
|
+
return c.text('Too many requests', 429, {
|
|
328
|
+
'access-control-allow-origin': '*',
|
|
329
|
+
});
|
|
330
|
+
return artifactDataResponse(c.env, c.req.param('artifactId'));
|
|
331
|
+
});
|
|
332
|
+
|
|
135
333
|
app.use('*', async (c, next) => {
|
|
136
334
|
const origin = c.env.CORS_ORIGIN ?? '*';
|
|
137
335
|
return cors({
|
|
@@ -161,43 +359,118 @@ export function createShell(
|
|
|
161
359
|
timestamp: new Date().toISOString(),
|
|
162
360
|
}),
|
|
163
361
|
);
|
|
362
|
+
// `frontendTools` is the frontend bridge version: the Portal enables
|
|
363
|
+
// conversational writes only against a runtime that advertises it. A
|
|
364
|
+
// version signal, not proof the deployment behaves (see frontend-bridge.md).
|
|
164
365
|
app.get('/health', (c) =>
|
|
165
|
-
c.json({
|
|
366
|
+
c.json({
|
|
367
|
+
status: 'ok',
|
|
368
|
+
timestamp: new Date().toISOString(),
|
|
369
|
+
frontendTools: FRONTEND_BRIDGE,
|
|
370
|
+
}),
|
|
166
371
|
);
|
|
372
|
+
// A liveness probe: the gateway's in-memory flag, no storage read. The
|
|
373
|
+
// full picture is `/matrix/status`.
|
|
167
374
|
app.get('/health/matrix', async (c) => {
|
|
168
|
-
const
|
|
169
|
-
|
|
375
|
+
const running = await gatewayRunning(c.env).catch((err: unknown) => {
|
|
376
|
+
console.warn(`[shell] gateway health check failed: ${errorText(err)}`);
|
|
377
|
+
return false;
|
|
378
|
+
});
|
|
379
|
+
return c.json({ running }, running ? 200 : 503);
|
|
170
380
|
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
c.json(
|
|
177
|
-
|
|
381
|
+
// Public (monitors and the drills poll it), so limited per IP; the
|
|
382
|
+
// gateway serves the storage-scanning part from a short cache.
|
|
383
|
+
app.get('/matrix/status', async (c) => {
|
|
384
|
+
if (await ipOverLimit(c.env, 'status', c.req.header('cf-connecting-ip')))
|
|
385
|
+
return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
|
|
386
|
+
return c.json(await gateway(c.env).status());
|
|
387
|
+
});
|
|
388
|
+
app.post('/matrix/start', async (c) => {
|
|
389
|
+
if (await ipOverLimit(c.env, 'start', c.req.header('cf-connecting-ip')))
|
|
390
|
+
return c.json({ statusCode: 429, message: 'Too many requests' }, 429);
|
|
391
|
+
return c.json(await gateway(c.env).ensureStarted());
|
|
392
|
+
});
|
|
393
|
+
app.get('/models', async (c) => {
|
|
394
|
+
const waitUntil = executionWaitUntil(c);
|
|
395
|
+
return c.json(
|
|
396
|
+
(await opts.listModels?.(c.env, waitUntil ? { waitUntil } : {})) ?? {
|
|
178
397
|
models: [],
|
|
179
398
|
// Node's `ModelListing` shape (`{ models, default }`); the client SDK
|
|
180
399
|
// reads `default`.
|
|
181
400
|
default: c.env.DEFAULT_MODEL ?? null,
|
|
182
401
|
},
|
|
183
|
-
)
|
|
184
|
-
);
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
app.post('/channels/turn', async (c) => {
|
|
406
|
+
try {
|
|
407
|
+
if (!c.env.CHANNEL_SERVICE_DID)
|
|
408
|
+
return c.json({ message: 'Channels are not configured' }, 503);
|
|
409
|
+
const raw = await readChannelBody(c.req.raw);
|
|
410
|
+
let json: unknown;
|
|
411
|
+
try {
|
|
412
|
+
json = JSON.parse(raw);
|
|
413
|
+
} catch {
|
|
414
|
+
throw new ChannelError(400, 'Invalid JSON request');
|
|
415
|
+
}
|
|
416
|
+
const parsed = ChannelTurnBody.safeParse(json);
|
|
417
|
+
if (!parsed.success) throw new ChannelError(400, 'Invalid channel turn');
|
|
418
|
+
const identity = await authenticateChannel(
|
|
419
|
+
c.req.raw.headers,
|
|
420
|
+
raw,
|
|
421
|
+
parsed.data,
|
|
422
|
+
channelAuthConfig(c.env),
|
|
423
|
+
);
|
|
424
|
+
// Limited as soon as the user is proven, before the Auth Hub round
|
|
425
|
+
// trip: a flood or a tight poll must not cost one call there each.
|
|
426
|
+
if (
|
|
427
|
+
c.env.RATE_LIMIT &&
|
|
428
|
+
!(
|
|
429
|
+
await c.env.RATE_LIMIT.limit({
|
|
430
|
+
key: rateLimitKey(c.env, 'user', identity.userDid),
|
|
431
|
+
})
|
|
432
|
+
).success
|
|
433
|
+
)
|
|
434
|
+
throw new ChannelError(429, 'Too many requests');
|
|
435
|
+
await assertActiveChannelBinding(identity, c.env);
|
|
436
|
+
const outcome = await userStub(c.env, identity.userDid).channelTurn(
|
|
437
|
+
identity,
|
|
438
|
+
parsed.data,
|
|
439
|
+
await channelRequestHash(raw),
|
|
440
|
+
);
|
|
441
|
+
if (!outcome.ok)
|
|
442
|
+
return c.json({ message: outcome.message }, outcome.status);
|
|
443
|
+
const result = outcome.result;
|
|
444
|
+
return c.json(
|
|
445
|
+
result,
|
|
446
|
+
['queued', 'running', 'recovering'].includes(result.status) ? 202 : 200,
|
|
447
|
+
);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
if (error instanceof ChannelError)
|
|
450
|
+
return c.json({ message: error.message }, error.status);
|
|
451
|
+
return c.json(
|
|
452
|
+
{ message: 'Channel turn is temporarily unavailable' },
|
|
453
|
+
503,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
185
457
|
|
|
186
458
|
// --- auth ------------------------------------------------------------------
|
|
187
459
|
app.use('*', async (c, next) => {
|
|
188
460
|
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
|
-
});
|
|
461
|
+
const outcome = await authenticate(
|
|
462
|
+
c.req.raw.headers,
|
|
463
|
+
authConfigFromEnv(c.env),
|
|
464
|
+
);
|
|
196
465
|
if (!outcome.ok)
|
|
197
466
|
return c.json(
|
|
198
467
|
{ statusCode: outcome.status, message: outcome.error },
|
|
199
468
|
outcome.status as 401,
|
|
200
469
|
);
|
|
470
|
+
if (outcome.auth.via === 'delegation')
|
|
471
|
+
console.warn(
|
|
472
|
+
`[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`,
|
|
473
|
+
);
|
|
201
474
|
c.set('auth', outcome.auth);
|
|
202
475
|
return next();
|
|
203
476
|
});
|
|
@@ -206,15 +479,17 @@ export function createShell(
|
|
|
206
479
|
// Cloudflare's native limiter; the limit/period live on the binding in
|
|
207
480
|
// wrangler.jsonc. Keyed by DID rather than IP so co-located users (one
|
|
208
481
|
// 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).
|
|
482
|
+
// unbounded LLM spend; the key carries the oracle's DID (`rateLimitKey`).
|
|
483
|
+
// Auth-excluded routes (health/models/matrix status) ran `next()` above
|
|
484
|
+
// and never reach here. Absent binding = no limiting (local harness / tests).
|
|
212
485
|
app.use('*', async (c, next) => {
|
|
213
486
|
const limiter = c.env.RATE_LIMIT;
|
|
214
487
|
if (!limiter) return next();
|
|
215
488
|
const auth = c.get('auth') as AuthResult | undefined;
|
|
216
489
|
if (!auth) return next();
|
|
217
|
-
const { success } = await limiter.limit({
|
|
490
|
+
const { success } = await limiter.limit({
|
|
491
|
+
key: rateLimitKey(c.env, 'user', auth.userDid),
|
|
492
|
+
});
|
|
218
493
|
if (!success)
|
|
219
494
|
return c.json(
|
|
220
495
|
{
|
|
@@ -261,8 +536,89 @@ export function createShell(
|
|
|
261
536
|
: c.json({ message: 'Session not found' }, 404);
|
|
262
537
|
});
|
|
263
538
|
|
|
539
|
+
app.use('/topic-deliverables/*', async (c, next) => {
|
|
540
|
+
if (c.env.TOPIC_DELIVERABLES_ENABLED !== 'true')
|
|
541
|
+
return c.json({ message: 'Not found.' }, 404);
|
|
542
|
+
if (c.get('auth')?.via !== 'invocation')
|
|
543
|
+
return c.json({ message: 'A signed invocation is required.' }, 401);
|
|
544
|
+
return next();
|
|
545
|
+
});
|
|
546
|
+
app.use(
|
|
547
|
+
'/topic-deliverables/*',
|
|
548
|
+
bodyLimit({
|
|
549
|
+
maxSize: TOPIC_DELIVERABLE_BODY_BYTES,
|
|
550
|
+
onError: (c) => c.json({ message: 'Request is too large.' }, 413),
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
app.on(
|
|
554
|
+
['PUT', 'GET', 'POST'],
|
|
555
|
+
[
|
|
556
|
+
'/topic-deliverables/:operationId',
|
|
557
|
+
'/topic-deliverables/:operationId/cancel',
|
|
558
|
+
],
|
|
559
|
+
async (c) => {
|
|
560
|
+
const cancelling =
|
|
561
|
+
c.req.routePath === '/topic-deliverables/:operationId/cancel';
|
|
562
|
+
if (cancelling !== (c.req.method === 'POST'))
|
|
563
|
+
return c.json({ message: 'Not found.' }, 404);
|
|
564
|
+
const operationId = TopicOperationId.safeParse(
|
|
565
|
+
c.req.param('operationId'),
|
|
566
|
+
);
|
|
567
|
+
if (!operationId.success)
|
|
568
|
+
return c.json({ message: 'Invalid operation ID.' }, 400);
|
|
569
|
+
let command: TopicDeliverableCommand;
|
|
570
|
+
if (c.req.method === 'PUT' || cancelling) {
|
|
571
|
+
const parsed = TopicDeliverableRequestSchema.safeParse(
|
|
572
|
+
await c.req.json().catch(() => null),
|
|
573
|
+
);
|
|
574
|
+
if (!parsed.success)
|
|
575
|
+
return c.json({ message: 'Invalid deliverable request.' }, 400);
|
|
576
|
+
command = {
|
|
577
|
+
action: cancelling ? 'cancel' : 'start',
|
|
578
|
+
request: parsed.data,
|
|
579
|
+
};
|
|
580
|
+
} else {
|
|
581
|
+
command = { action: 'read' };
|
|
582
|
+
}
|
|
583
|
+
const auth = c.get('auth');
|
|
584
|
+
const result = await userStub(c.env, auth.userDid).topicDeliverable(
|
|
585
|
+
identityOf(auth, c.req.raw.headers),
|
|
586
|
+
operationId.data,
|
|
587
|
+
command,
|
|
588
|
+
);
|
|
589
|
+
return result.ok
|
|
590
|
+
? c.json(result.snapshot)
|
|
591
|
+
: c.json({ message: result.message }, result.status);
|
|
592
|
+
},
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// --- artefacts (owner) ---------------------------------------------------------
|
|
596
|
+
app.get('/artifacts/:artifactId', async (c) => {
|
|
597
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
598
|
+
const artifactId = c.req.param('artifactId');
|
|
599
|
+
const artifact = ARTIFACT_ID_RE.test(artifactId)
|
|
600
|
+
? await userStub(c.env, identity.userDid).artifact(identity, artifactId)
|
|
601
|
+
: null;
|
|
602
|
+
return artifact
|
|
603
|
+
? c.json(artifact)
|
|
604
|
+
: c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
|
|
605
|
+
});
|
|
606
|
+
app.delete('/artifacts/:artifactId', async (c) => {
|
|
607
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
608
|
+
const artifactId = c.req.param('artifactId');
|
|
609
|
+
const revoked =
|
|
610
|
+
ARTIFACT_ID_RE.test(artifactId) &&
|
|
611
|
+
(await userStub(c.env, identity.userDid).revokeArtifact(
|
|
612
|
+
identity,
|
|
613
|
+
artifactId,
|
|
614
|
+
));
|
|
615
|
+
return revoked
|
|
616
|
+
? c.json({ revoked: true })
|
|
617
|
+
: c.json({ statusCode: 404, message: 'Artefact not found' }, 404);
|
|
618
|
+
});
|
|
619
|
+
|
|
264
620
|
// --- messages ----------------------------------------------------------------
|
|
265
|
-
app.post('/messages/abort', async (c) => {
|
|
621
|
+
app.post('/messages/abort', limitBody(ABORT_BODY_BYTES), async (c) => {
|
|
266
622
|
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
267
623
|
const body = (await c.req.json().catch(() => ({}))) as {
|
|
268
624
|
sessionId?: string;
|
|
@@ -280,10 +636,43 @@ export function createShell(
|
|
|
280
636
|
identity,
|
|
281
637
|
c.req.param('sessionId'),
|
|
282
638
|
);
|
|
283
|
-
return new Response(
|
|
284
|
-
|
|
285
|
-
|
|
639
|
+
return new Response(
|
|
640
|
+
withFeedbackCapability(`{"messages":${messages}}`, !!opts.feedback),
|
|
641
|
+
{ headers: { 'content-type': 'application/json' } },
|
|
642
|
+
);
|
|
286
643
|
});
|
|
644
|
+
// Anonymous feedback on one completed Agent reply (src/feedback/submit.ts).
|
|
645
|
+
app.post(
|
|
646
|
+
'/messages/:sessionId/:messageId/feedback',
|
|
647
|
+
bodyLimit({
|
|
648
|
+
maxSize: FEEDBACK_BODY_MAX_BYTES,
|
|
649
|
+
onError: (c) =>
|
|
650
|
+
c.json({ statusCode: 413, message: 'request entity too large' }, 413),
|
|
651
|
+
}),
|
|
652
|
+
async (c) => {
|
|
653
|
+
if (!opts.feedback) {
|
|
654
|
+
const off = feedbackFailure(
|
|
655
|
+
404,
|
|
656
|
+
'FEEDBACK_DISABLED',
|
|
657
|
+
'Anonymous feedback is not enabled on this oracle',
|
|
658
|
+
);
|
|
659
|
+
return c.json(off.body, off.status);
|
|
660
|
+
}
|
|
661
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
662
|
+
const result = await submitFeedback({
|
|
663
|
+
options: opts.feedback,
|
|
664
|
+
user: userStub(c.env, identity.userDid),
|
|
665
|
+
identity,
|
|
666
|
+
sessionId: c.req.param('sessionId'),
|
|
667
|
+
messageId: c.req.param('messageId'),
|
|
668
|
+
body: await c.req.json<unknown>().catch(() => undefined),
|
|
669
|
+
clientIp: c.req.header('cf-connecting-ip'),
|
|
670
|
+
ipLimiter: c.env.FEEDBACK_RATE_LIMIT ?? c.env.RATE_LIMIT,
|
|
671
|
+
log: console,
|
|
672
|
+
});
|
|
673
|
+
return c.json(result.body, result.status);
|
|
674
|
+
},
|
|
675
|
+
);
|
|
287
676
|
// One turn-aligned page of the transcript (docs/plans/transcript-paging.md):
|
|
288
677
|
// the newest `limit` turns, the turns `before=` a cursor, or what came
|
|
289
678
|
// `after=` one. The legacy listing above stays the whole transcript.
|
|
@@ -299,7 +688,7 @@ export function createShell(
|
|
|
299
688
|
);
|
|
300
689
|
if (!page.ok)
|
|
301
690
|
return c.json({ statusCode: page.status, message: page.message }, 400);
|
|
302
|
-
return new Response(page.json, {
|
|
691
|
+
return new Response(withFeedbackCapability(page.json, !!opts.feedback), {
|
|
303
692
|
headers: { 'content-type': 'application/json' },
|
|
304
693
|
});
|
|
305
694
|
});
|
|
@@ -332,53 +721,64 @@ export function createShell(
|
|
|
332
721
|
);
|
|
333
722
|
return new Response(res.body, { status: res.status, headers });
|
|
334
723
|
});
|
|
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
|
-
|
|
724
|
+
// The cap bounds the memory one request can pin (see turn-body-cap.ts); it
|
|
725
|
+
// is enforced before the body is read, with the status and message Node's
|
|
726
|
+
// body parser uses.
|
|
727
|
+
app.post(
|
|
728
|
+
'/messages/:sessionId',
|
|
729
|
+
limitBody(MAX_TURN_BODY_BYTES),
|
|
730
|
+
async (c) => {
|
|
731
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
732
|
+
const sessionId = c.req.param('sessionId');
|
|
733
|
+
const body = await c.req.text();
|
|
734
|
+
// Node accepts a client-supplied `requestId` (correlates the SSE events
|
|
735
|
+
// with the client's own bookkeeping); fall back to a fresh UUID.
|
|
736
|
+
const requestId = clientRequestId(body) ?? crypto.randomUUID();
|
|
737
|
+
// The user object owns the turn; it returns either an SSE stream or JSON.
|
|
738
|
+
// We pass the raw body through so attachments/tools/agActions survive.
|
|
739
|
+
const res = await userStub(c.env, identity.userDid).fetch(
|
|
740
|
+
`https://user-oracle/turn/${encodeURIComponent(sessionId)}`,
|
|
741
|
+
{
|
|
742
|
+
method: 'POST',
|
|
743
|
+
headers: {
|
|
744
|
+
'content-type': 'application/json',
|
|
745
|
+
'x-request-id': requestId,
|
|
746
|
+
'x-identity': JSON.stringify(identity),
|
|
747
|
+
},
|
|
748
|
+
body,
|
|
360
749
|
},
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
});
|
|
750
|
+
);
|
|
751
|
+
const headers = new Headers(res.headers);
|
|
752
|
+
headers.set('x-request-id', requestId);
|
|
753
|
+
headers.set('access-control-expose-headers', 'x-request-id');
|
|
754
|
+
return new Response(res.body, { status: res.status, headers });
|
|
755
|
+
},
|
|
756
|
+
);
|
|
369
757
|
|
|
370
758
|
// --- delegation (room-state persisted, for header-less Matrix turns) -----------
|
|
371
|
-
app.post('/delegation', async (c) => {
|
|
759
|
+
app.post('/delegation', limitBody(DELEGATION_BODY_BYTES), async (c) => {
|
|
372
760
|
const auth = c.get('auth');
|
|
373
|
-
const body = (await c.req.json().catch(() => ({}))) as {
|
|
374
|
-
raw?: string;
|
|
375
|
-
expiration?: number;
|
|
376
|
-
};
|
|
761
|
+
const body = (await c.req.json().catch(() => ({}))) as { raw?: string };
|
|
377
762
|
const raw = body.raw ?? auth.delegation;
|
|
378
763
|
if (!raw) return c.json({ message: 'raw delegation is required' }, 400);
|
|
764
|
+
// What is stored here authorizes header-less turns long after this
|
|
765
|
+
// request, so it gets the header's checks: for this oracle, issued by
|
|
766
|
+
// the caller, with a bounded expiry — and that expiry is the token's,
|
|
767
|
+
// never a value the client states beside it.
|
|
768
|
+
const checked = await validateDelegation(raw, authConfigFromEnv(c.env));
|
|
769
|
+
if (!checked.ok)
|
|
770
|
+
return c.json(
|
|
771
|
+
{ message: `Invalid UCAN delegation: ${checked.error}` },
|
|
772
|
+
400,
|
|
773
|
+
);
|
|
774
|
+
if (checked.userDid !== auth.userDid)
|
|
775
|
+
return c.json(
|
|
776
|
+
{ message: 'The delegation was not issued by the authenticated user' },
|
|
777
|
+
403,
|
|
778
|
+
);
|
|
379
779
|
const room = await gateway(c.env).resolveUserRoom(auth.userDid);
|
|
380
780
|
if (!room) return c.json({ message: 'No oracle room for this user' }, 404);
|
|
381
|
-
const expiration =
|
|
781
|
+
const { expiration } = checked;
|
|
382
782
|
// The Node runtime's `DelegationStore` record, in its compressed
|
|
383
783
|
// room-state envelope, so a user can move between runtimes without
|
|
384
784
|
// re-authorising (`updatedAt` is required by Node's schema).
|
|
@@ -390,7 +790,7 @@ export function createShell(
|
|
|
390
790
|
raw,
|
|
391
791
|
issuer: auth.userDid,
|
|
392
792
|
audience: c.env.ORACLE_DID,
|
|
393
|
-
|
|
793
|
+
expiration,
|
|
394
794
|
updatedAt: new Date().toISOString(),
|
|
395
795
|
}),
|
|
396
796
|
),
|
|
@@ -406,10 +806,7 @@ export function createShell(
|
|
|
406
806
|
`[shell] could not push the deposited delegation to ${auth.userDid}'s object: ${err instanceof Error ? err.message : String(err)}`,
|
|
407
807
|
);
|
|
408
808
|
});
|
|
409
|
-
return c.json({
|
|
410
|
-
ok: true,
|
|
411
|
-
expiration: body.expiration ?? auth.delegationExpiration,
|
|
412
|
-
});
|
|
809
|
+
return c.json({ ok: true, expiration });
|
|
413
810
|
});
|
|
414
811
|
app.get('/delegation', async (c) => {
|
|
415
812
|
const auth = c.get('auth');
|
|
@@ -477,6 +874,7 @@ export function createShell(
|
|
|
477
874
|
// so every route is proxied there — same pattern as streaming turns. The
|
|
478
875
|
// shell only short-circuits the disabled case so stray requests never boot
|
|
479
876
|
// a Durable Object.
|
|
877
|
+
app.use('/byo-llm/*', limitBody(BYO_LLM_BODY_BYTES));
|
|
480
878
|
app.all('/byo-llm/*', async (c) => {
|
|
481
879
|
if (c.env.BYO_LLM_ENABLED !== 'true') {
|
|
482
880
|
return c.req.method === 'GET' && c.req.path === '/byo-llm/status'
|
|
@@ -514,6 +912,20 @@ export function createShell(
|
|
|
514
912
|
return c.json({ statusCode: 404, message: 'Not found' }, 404);
|
|
515
913
|
return next();
|
|
516
914
|
});
|
|
915
|
+
app.use('/debug/*', limitBody(DEBUG_BODY_BYTES));
|
|
916
|
+
// The gateway-wide routes act on the one gateway every user depends on
|
|
917
|
+
// (stop, restart, reset, rotate, every user's outbox). With
|
|
918
|
+
// ORACLE_OPERATOR_DIDS set only those DIDs may call them; unset, any
|
|
919
|
+
// authenticated caller may (local and devnet drills).
|
|
920
|
+
app.use('/debug/matrix/*', async (c, next) => {
|
|
921
|
+
const operators = operatorDids(c.env);
|
|
922
|
+
if (operators && !operators.has(c.get('auth').userDid))
|
|
923
|
+
return c.json(
|
|
924
|
+
{ statusCode: 403, message: 'Operator routes are restricted' },
|
|
925
|
+
403,
|
|
926
|
+
);
|
|
927
|
+
return next();
|
|
928
|
+
});
|
|
517
929
|
app.get('/debug/storage', async (c) =>
|
|
518
930
|
c.json(await userStub(c.env, c.get('auth').userDid).storageStatus()),
|
|
519
931
|
);
|
|
@@ -533,7 +945,11 @@ export function createShell(
|
|
|
533
945
|
);
|
|
534
946
|
});
|
|
535
947
|
app.post('/debug/storage/reset', async (c) =>
|
|
536
|
-
c.json(
|
|
948
|
+
c.json(
|
|
949
|
+
await userStub(c.env, c.get('auth').userDid).resetWorkingCopy(
|
|
950
|
+
identityOf(c.get('auth'), c.req.raw.headers),
|
|
951
|
+
),
|
|
952
|
+
),
|
|
537
953
|
);
|
|
538
954
|
app.post('/debug/object/abort', async (c) => {
|
|
539
955
|
try {
|
|
@@ -665,7 +1081,15 @@ export function createShell(
|
|
|
665
1081
|
),
|
|
666
1082
|
);
|
|
667
1083
|
app.onError((err, c) => {
|
|
668
|
-
|
|
1084
|
+
// Thrown on purpose with a status and a message meant for the client.
|
|
1085
|
+
if (err instanceof HTTPException) return err.getResponse();
|
|
1086
|
+
if (err instanceof ChannelError)
|
|
1087
|
+
return c.json({ message: err.message }, err.status);
|
|
1088
|
+
const requestId = errorRequestId(c.req.header('x-request-id'));
|
|
1089
|
+
console.error(
|
|
1090
|
+
`[shell] ${c.req.method} ${loggedRoute(c)} failed (request ${requestId}):`,
|
|
1091
|
+
err,
|
|
1092
|
+
);
|
|
669
1093
|
// A cold boot that could not read the user's owner copy changed nothing.
|
|
670
1094
|
// Say precisely why: 503 + retryable for a transient store/VFS failure
|
|
671
1095
|
// (the object already retried with backoff), 403 for a missing
|
|
@@ -686,9 +1110,16 @@ export function createShell(
|
|
|
686
1110
|
failure.httpStatus,
|
|
687
1111
|
);
|
|
688
1112
|
}
|
|
1113
|
+
// Anything else may carry internals (SQL, homeserver or Blocksync error
|
|
1114
|
+
// text, room ids): the client gets a generic message and the id that
|
|
1115
|
+
// finds the details in the log.
|
|
689
1116
|
return c.json(
|
|
690
|
-
{ statusCode: 500, message:
|
|
1117
|
+
{ statusCode: 500, message: 'Internal server error', requestId },
|
|
691
1118
|
500,
|
|
1119
|
+
{
|
|
1120
|
+
'x-request-id': requestId,
|
|
1121
|
+
'access-control-expose-headers': 'x-request-id',
|
|
1122
|
+
},
|
|
692
1123
|
);
|
|
693
1124
|
});
|
|
694
1125
|
|
|
@@ -697,6 +1128,11 @@ export function createShell(
|
|
|
697
1128
|
|
|
698
1129
|
const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
699
1130
|
|
|
1131
|
+
/** The client's `x-request-id` when it is a sane opaque token, else a fresh one. */
|
|
1132
|
+
function errorRequestId(header: string | undefined): string {
|
|
1133
|
+
return header && REQUEST_ID_RE.test(header) ? header : crypto.randomUUID();
|
|
1134
|
+
}
|
|
1135
|
+
|
|
700
1136
|
/** The body's `requestId` when it is a sane opaque token; null otherwise. */
|
|
701
1137
|
function clientRequestId(rawBody: string): string | null {
|
|
702
1138
|
try {
|
|
@@ -711,6 +1147,17 @@ function clientRequestId(rawBody: string): string | null {
|
|
|
711
1147
|
return null;
|
|
712
1148
|
}
|
|
713
1149
|
|
|
1150
|
+
/**
|
|
1151
|
+
* The route pattern a request matched (`/messages/:sessionId`), never the
|
|
1152
|
+
* concrete path: a path carries session and message ids, and a log line
|
|
1153
|
+
* that pairs them with the caller's DID could tie an anonymous feedback
|
|
1154
|
+
* submission to its author. In a middleware `c.req.routePath` is the
|
|
1155
|
+
* middleware's own `*`, so the handler's pattern (the last match) is used.
|
|
1156
|
+
*/
|
|
1157
|
+
function loggedRoute(c: Parameters<typeof routePath>[0]): string {
|
|
1158
|
+
return routePath(c, -1) || '(unmatched)';
|
|
1159
|
+
}
|
|
1160
|
+
|
|
714
1161
|
function identityOf(auth: AuthResult, headers: Headers): TurnIdentity {
|
|
715
1162
|
return {
|
|
716
1163
|
userDid: auth.userDid,
|