@ixo/oracle-runtime-workers 0.2.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 +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- package/src/tasks/store.ts +351 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The realtime clients of one user object: every accepted WebSocket, the
|
|
3
|
+
* session it subscribed to, and its handshake state. This is the Workers
|
|
4
|
+
* counterpart of the Node runtime's `WsService` (`sessionConnections`) plus
|
|
5
|
+
* the socket.io room `server.to(sessionId)` that `BaseEvent` fans out to.
|
|
6
|
+
*
|
|
7
|
+
* Sockets are Hibernatable WebSockets: everything the hub knows about a
|
|
8
|
+
* socket — identity, handshake state AND the heartbeat bookkeeping (last
|
|
9
|
+
* ping sent, last pong seen) — lives in the per-socket attachment
|
|
10
|
+
* (`serializeAttachment`), so the set is rebuilt losslessly from
|
|
11
|
+
* `ctx.getWebSockets()` whenever the object wakes from hibernation or
|
|
12
|
+
* restarts. Nothing here is kept only in memory.
|
|
13
|
+
*/
|
|
14
|
+
import { ENGINE_PING, encodeEvent } from './socket-io-codec';
|
|
15
|
+
|
|
16
|
+
export interface SocketAttachment {
|
|
17
|
+
/** engine.io session id (the `sid` from the OPEN handshake). */
|
|
18
|
+
sid: string;
|
|
19
|
+
/** Oracle session the socket subscribed to (`?sessionId=`). */
|
|
20
|
+
sessionId: string;
|
|
21
|
+
/** User DID the shell routed the socket for (`?userDid=`). */
|
|
22
|
+
routedUserDid: string;
|
|
23
|
+
/** Set once the CONNECT packet's UCAN auth validated. */
|
|
24
|
+
userDid?: string;
|
|
25
|
+
openedAt: number;
|
|
26
|
+
/** Last engine.io pong (`3`) seen — at open, "now". */
|
|
27
|
+
lastPongAt: number;
|
|
28
|
+
/** Last engine.io ping (`2`) sent; unset until the first heartbeat round. */
|
|
29
|
+
lastPingAt?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The subset of the Workers `WebSocket` the hub needs (tests use fakes). */
|
|
33
|
+
export interface HubSocket {
|
|
34
|
+
send(message: string): void;
|
|
35
|
+
close(code?: number, reason?: string): void;
|
|
36
|
+
serializeAttachment(value: unknown): void;
|
|
37
|
+
deserializeAttachment(): unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SessionSocketHubOptions {
|
|
41
|
+
logger?: { warn: (message: string) => void };
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return typeof value === 'object' && value !== null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAttachment(value: unknown): value is SocketAttachment {
|
|
50
|
+
if (!isRecord(value)) return false;
|
|
51
|
+
const record = value;
|
|
52
|
+
return (
|
|
53
|
+
typeof record.sid === 'string' &&
|
|
54
|
+
typeof record.sessionId === 'string' &&
|
|
55
|
+
typeof record.routedUserDid === 'string' &&
|
|
56
|
+
typeof record.openedAt === 'number'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Attachments written before the heartbeat fields existed get them on restore. */
|
|
61
|
+
function withHeartbeat(
|
|
62
|
+
attachment: SocketAttachment,
|
|
63
|
+
now: number,
|
|
64
|
+
): SocketAttachment {
|
|
65
|
+
return typeof attachment.lastPongAt === 'number'
|
|
66
|
+
? attachment
|
|
67
|
+
: { ...attachment, lastPongAt: now };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SessionSocketHub<S extends HubSocket = HubSocket> {
|
|
71
|
+
private readonly sockets = new Map<S, SocketAttachment>();
|
|
72
|
+
|
|
73
|
+
private readonly logger: { warn: (message: string) => void };
|
|
74
|
+
|
|
75
|
+
private readonly now: () => number;
|
|
76
|
+
|
|
77
|
+
constructor(options: SessionSocketHubOptions = {}) {
|
|
78
|
+
this.logger = options.logger ?? { warn: () => undefined };
|
|
79
|
+
this.now = options.now ?? (() => Date.now());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Register a freshly accepted socket and persist its identity on it. */
|
|
83
|
+
add(socket: S, attachment: SocketAttachment): void {
|
|
84
|
+
socket.serializeAttachment(attachment);
|
|
85
|
+
this.sockets.set(socket, attachment);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rebuild the set from the hibernated sockets (object wake or restart).
|
|
90
|
+
* The heartbeat bookkeeping comes back from the attachment untouched, so a
|
|
91
|
+
* socket that stopped answering before the object slept is still caught.
|
|
92
|
+
*/
|
|
93
|
+
restore(sockets: readonly S[]): number {
|
|
94
|
+
let restored = 0;
|
|
95
|
+
const now = this.now();
|
|
96
|
+
for (const socket of sockets) {
|
|
97
|
+
const attachment = socket.deserializeAttachment();
|
|
98
|
+
if (!isAttachment(attachment)) {
|
|
99
|
+
// Not one of ours (or a pre-handshake socket whose attachment was
|
|
100
|
+
// never written): nothing to route to, close it.
|
|
101
|
+
socket.close(1011, 'unknown socket');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
this.sockets.set(socket, withHeartbeat(attachment, now));
|
|
105
|
+
restored += 1;
|
|
106
|
+
}
|
|
107
|
+
return restored;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get(socket: S): SocketAttachment | undefined {
|
|
111
|
+
return this.sockets.get(socket);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Update the persisted attachment (e.g. after the CONNECT auth validated). */
|
|
115
|
+
update(
|
|
116
|
+
socket: S,
|
|
117
|
+
patch: Partial<SocketAttachment>,
|
|
118
|
+
): SocketAttachment | undefined {
|
|
119
|
+
const current = this.sockets.get(socket);
|
|
120
|
+
if (!current) return undefined;
|
|
121
|
+
const next = { ...current, ...patch };
|
|
122
|
+
socket.serializeAttachment(next);
|
|
123
|
+
this.sockets.set(socket, next);
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
remove(socket: S): void {
|
|
128
|
+
this.sockets.delete(socket);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A pong arrived: persist the time (the object may sleep right after). */
|
|
132
|
+
notePong(socket: S): void {
|
|
133
|
+
if (this.sockets.has(socket))
|
|
134
|
+
this.update(socket, { lastPongAt: this.now() });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Every socket with its heartbeat bookkeeping (diagnostics). */
|
|
138
|
+
entries(): Array<{ socket: S; attachment: SocketAttachment }> {
|
|
139
|
+
return [...this.sockets].map(([socket, attachment]) => ({
|
|
140
|
+
socket,
|
|
141
|
+
attachment,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Authenticated sockets subscribed to `sessionId`. */
|
|
146
|
+
forSession(sessionId: string): S[] {
|
|
147
|
+
const out: S[] = [];
|
|
148
|
+
for (const [socket, meta] of this.sockets) {
|
|
149
|
+
if (meta.userDid && meta.sessionId === sessionId) out.push(socket);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
hasSession(sessionId: string): boolean {
|
|
155
|
+
return this.forSession(sessionId).length > 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Fan an event out to every authenticated socket of the session. */
|
|
159
|
+
emitToSession(
|
|
160
|
+
sessionId: string,
|
|
161
|
+
eventName: string,
|
|
162
|
+
payload: unknown,
|
|
163
|
+
): number {
|
|
164
|
+
const frame = encodeEvent(eventName, payload);
|
|
165
|
+
let sent = 0;
|
|
166
|
+
for (const socket of this.forSession(sessionId)) {
|
|
167
|
+
if (this.trySend(socket, frame)) sent += 1;
|
|
168
|
+
}
|
|
169
|
+
return sent;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
send(socket: S, frame: string): boolean {
|
|
173
|
+
return this.trySend(socket, frame);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One heartbeat round (run from the object's alarm, never from a timer):
|
|
178
|
+
* close every socket whose last pong is older than
|
|
179
|
+
* `pingIntervalMs + pingTimeoutMs` — the same rule the client applies in
|
|
180
|
+
* the other direction — and ping the rest, recording the ping time on the
|
|
181
|
+
* socket. Returns the sockets closed.
|
|
182
|
+
*/
|
|
183
|
+
heartbeat(pingIntervalMs: number, pingTimeoutMs: number): S[] {
|
|
184
|
+
const now = this.now();
|
|
185
|
+
const closed: S[] = [];
|
|
186
|
+
for (const [socket, meta] of [...this.sockets]) {
|
|
187
|
+
if (now - meta.lastPongAt > pingIntervalMs + pingTimeoutMs) {
|
|
188
|
+
this.remove(socket);
|
|
189
|
+
try {
|
|
190
|
+
socket.close(4408, 'ping timeout');
|
|
191
|
+
} catch {
|
|
192
|
+
// already gone
|
|
193
|
+
}
|
|
194
|
+
closed.push(socket);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (this.trySend(socket, ENGINE_PING))
|
|
198
|
+
this.update(socket, { lastPingAt: now });
|
|
199
|
+
}
|
|
200
|
+
return closed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* When the next heartbeat round is due: `pingIntervalMs` after the most
|
|
205
|
+
* overdue socket's last ping (its open time before the first round), or
|
|
206
|
+
* null with no sockets attached.
|
|
207
|
+
*/
|
|
208
|
+
nextPingAt(pingIntervalMs: number): number | null {
|
|
209
|
+
let next: number | null = null;
|
|
210
|
+
for (const meta of this.sockets.values()) {
|
|
211
|
+
const due = (meta.lastPingAt ?? meta.openedAt) + pingIntervalMs;
|
|
212
|
+
if (next === null || due < next) next = due;
|
|
213
|
+
}
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
get size(): number {
|
|
218
|
+
return this.sockets.size;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Distinct sessions with at least one authenticated socket. */
|
|
222
|
+
sessionCount(): number {
|
|
223
|
+
const ids = new Set<string>();
|
|
224
|
+
for (const meta of this.sockets.values()) {
|
|
225
|
+
if (meta.userDid) ids.add(meta.sessionId);
|
|
226
|
+
}
|
|
227
|
+
return ids.size;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Authenticated socket count (Node's `totalConnections`). */
|
|
231
|
+
connectionCount(): number {
|
|
232
|
+
let n = 0;
|
|
233
|
+
for (const meta of this.sockets.values()) if (meta.userDid) n += 1;
|
|
234
|
+
return n;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private trySend(socket: S, frame: string): boolean {
|
|
238
|
+
try {
|
|
239
|
+
socket.send(frame);
|
|
240
|
+
return true;
|
|
241
|
+
} catch (err) {
|
|
242
|
+
this.logger.warn(
|
|
243
|
+
`socket send failed, dropping socket: ${err instanceof Error ? err.message : String(err)}`,
|
|
244
|
+
);
|
|
245
|
+
this.remove(socket);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire codec for the socket.io v4 protocol over a raw WebSocket — the
|
|
3
|
+
* server half only, enough to speak to `socket.io-client` (the client SDK's
|
|
4
|
+
* `use-websocket-events` hook connects with `transports: ['websocket']`).
|
|
5
|
+
*
|
|
6
|
+
* Two layers, both text frames:
|
|
7
|
+
*
|
|
8
|
+
* engine.io v4 — one leading digit: `0` open, `1` close, `2` ping,
|
|
9
|
+
* `3` pong, `4` message, `5` upgrade, `6` noop. The server initiates
|
|
10
|
+
* pings; the client answers with `3`.
|
|
11
|
+
* socket.io v4 (inside a `4` message) — type digit, optional binary
|
|
12
|
+
* attachment count (`n-`), optional namespace (`/nsp,`), optional ack id
|
|
13
|
+
* (digits), then a JSON payload. Only the default namespace is served.
|
|
14
|
+
*
|
|
15
|
+
* Examples on the wire:
|
|
16
|
+
* server → `0{"sid":"…","upgrades":[],"pingInterval":25000,…}`
|
|
17
|
+
* client → `40{"invocation":"…"}` CONNECT with the auth object
|
|
18
|
+
* server → `40{"sid":"…"}` CONNECT acknowledged
|
|
19
|
+
* server → `44{"message":"Unauthorized"}` CONNECT_ERROR
|
|
20
|
+
* either → `42["tool_result",{…}]` EVENT
|
|
21
|
+
* client → `4212["ev",{}]` / server → `4312[]` EVENT with ack id 12 / ACK
|
|
22
|
+
*
|
|
23
|
+
* Binary events (types 5/6) are not supported: nothing this runtime emits is
|
|
24
|
+
* binary, and the client SDK never sends any.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const ENGINE_OPEN = '0';
|
|
28
|
+
export const ENGINE_CLOSE = '1';
|
|
29
|
+
export const ENGINE_PING = '2';
|
|
30
|
+
export const ENGINE_PONG = '3';
|
|
31
|
+
export const ENGINE_MESSAGE = '4';
|
|
32
|
+
|
|
33
|
+
const SIO_CONNECT = 0;
|
|
34
|
+
const SIO_DISCONNECT = 1;
|
|
35
|
+
const SIO_EVENT = 2;
|
|
36
|
+
const SIO_ACK = 3;
|
|
37
|
+
const SIO_CONNECT_ERROR = 4;
|
|
38
|
+
|
|
39
|
+
export interface OpenHandshake {
|
|
40
|
+
sid: string;
|
|
41
|
+
pingIntervalMs: number;
|
|
42
|
+
pingTimeoutMs: number;
|
|
43
|
+
maxPayloadBytes: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ClientFrame =
|
|
47
|
+
| { kind: 'ping' }
|
|
48
|
+
| { kind: 'pong' }
|
|
49
|
+
| { kind: 'close' }
|
|
50
|
+
| { kind: 'connect'; auth: unknown }
|
|
51
|
+
| { kind: 'disconnect' }
|
|
52
|
+
| { kind: 'event'; name: string; args: unknown[]; ackId?: number }
|
|
53
|
+
| { kind: 'ack'; id: number; args: unknown[] }
|
|
54
|
+
| { kind: 'unsupported'; reason: string; raw: string };
|
|
55
|
+
|
|
56
|
+
export function encodeOpen(handshake: OpenHandshake): string {
|
|
57
|
+
return (
|
|
58
|
+
ENGINE_OPEN +
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
sid: handshake.sid,
|
|
61
|
+
upgrades: [],
|
|
62
|
+
pingInterval: handshake.pingIntervalMs,
|
|
63
|
+
pingTimeout: handshake.pingTimeoutMs,
|
|
64
|
+
maxPayload: handshake.maxPayloadBytes,
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function encodeConnectAck(sid: string): string {
|
|
70
|
+
return `${ENGINE_MESSAGE}${SIO_CONNECT}${JSON.stringify({ sid })}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function encodeConnectError(message: string, data?: unknown): string {
|
|
74
|
+
return `${ENGINE_MESSAGE}${SIO_CONNECT_ERROR}${JSON.stringify(
|
|
75
|
+
data === undefined ? { message } : { message, data },
|
|
76
|
+
)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function encodeEvent(name: string, ...args: unknown[]): string {
|
|
80
|
+
return `${ENGINE_MESSAGE}${SIO_EVENT}${JSON.stringify([name, ...args])}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function encodeAck(id: number, args: unknown[] = []): string {
|
|
84
|
+
return `${ENGINE_MESSAGE}${SIO_ACK}${id}${JSON.stringify(args)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function encodeDisconnect(): string {
|
|
88
|
+
return `${ENGINE_MESSAGE}${SIO_DISCONNECT}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function unsupported(reason: string, raw: string): ClientFrame {
|
|
92
|
+
return { kind: 'unsupported', reason, raw };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseJson(text: string): { ok: true; value: unknown } | { ok: false } {
|
|
96
|
+
if (text.length === 0) return { ok: true, value: undefined };
|
|
97
|
+
try {
|
|
98
|
+
return { ok: true, value: JSON.parse(text) as unknown };
|
|
99
|
+
} catch {
|
|
100
|
+
return { ok: false };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decode one text frame from the client. Never throws: anything outside the
|
|
106
|
+
* supported protocol subset comes back as `unsupported` with a reason, so
|
|
107
|
+
* the caller can log it and decide whether to close.
|
|
108
|
+
*/
|
|
109
|
+
export function decodeClientFrame(raw: string): ClientFrame {
|
|
110
|
+
if (raw.length === 0) return unsupported('empty frame', raw);
|
|
111
|
+
const engineType = raw.charAt(0);
|
|
112
|
+
switch (engineType) {
|
|
113
|
+
case ENGINE_PING:
|
|
114
|
+
return { kind: 'ping' };
|
|
115
|
+
case ENGINE_PONG:
|
|
116
|
+
return { kind: 'pong' };
|
|
117
|
+
case ENGINE_CLOSE:
|
|
118
|
+
return { kind: 'close' };
|
|
119
|
+
case ENGINE_MESSAGE:
|
|
120
|
+
return decodeSocketIoPacket(raw.slice(1), raw);
|
|
121
|
+
default:
|
|
122
|
+
return unsupported(`engine.io packet type ${engineType}`, raw);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function decodeSocketIoPacket(packet: string, raw: string): ClientFrame {
|
|
127
|
+
if (packet.length === 0) return unsupported('empty socket.io packet', raw);
|
|
128
|
+
const type = Number(packet.charAt(0));
|
|
129
|
+
if (!Number.isInteger(type) || type < 0 || type > 6) {
|
|
130
|
+
return unsupported(`socket.io packet type ${packet.charAt(0)}`, raw);
|
|
131
|
+
}
|
|
132
|
+
if (type === 5 || type === 6) {
|
|
133
|
+
return unsupported('binary socket.io packets are not supported', raw);
|
|
134
|
+
}
|
|
135
|
+
let i = 1;
|
|
136
|
+
// Namespace: only the default one is served.
|
|
137
|
+
if (packet.charAt(i) === '/') {
|
|
138
|
+
const end = packet.indexOf(',', i);
|
|
139
|
+
const nsp = end === -1 ? packet.slice(i) : packet.slice(i, end);
|
|
140
|
+
if (nsp !== '/') return unsupported(`namespace ${nsp} is not served`, raw);
|
|
141
|
+
i = end === -1 ? packet.length : end + 1;
|
|
142
|
+
}
|
|
143
|
+
// Optional ack id: consecutive digits before the payload.
|
|
144
|
+
let idText = '';
|
|
145
|
+
while (
|
|
146
|
+
i < packet.length &&
|
|
147
|
+
packet.charAt(i) >= '0' &&
|
|
148
|
+
packet.charAt(i) <= '9'
|
|
149
|
+
) {
|
|
150
|
+
idText += packet.charAt(i);
|
|
151
|
+
i += 1;
|
|
152
|
+
}
|
|
153
|
+
const ackId = idText.length > 0 ? Number(idText) : undefined;
|
|
154
|
+
const payload = parseJson(packet.slice(i));
|
|
155
|
+
if (!payload.ok) return unsupported('malformed JSON payload', raw);
|
|
156
|
+
|
|
157
|
+
switch (type) {
|
|
158
|
+
case SIO_CONNECT:
|
|
159
|
+
return { kind: 'connect', auth: payload.value };
|
|
160
|
+
case SIO_DISCONNECT:
|
|
161
|
+
return { kind: 'disconnect' };
|
|
162
|
+
case SIO_EVENT: {
|
|
163
|
+
const value = payload.value;
|
|
164
|
+
if (!Array.isArray(value) || typeof value[0] !== 'string') {
|
|
165
|
+
return unsupported('event payload must be [name, ...args]', raw);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
kind: 'event',
|
|
169
|
+
name: value[0],
|
|
170
|
+
args: value.slice(1),
|
|
171
|
+
...(ackId !== undefined ? { ackId } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
case SIO_ACK: {
|
|
175
|
+
if (ackId === undefined) return unsupported('ack without id', raw);
|
|
176
|
+
const value = payload.value;
|
|
177
|
+
return {
|
|
178
|
+
kind: 'ack',
|
|
179
|
+
id: ackId,
|
|
180
|
+
args: Array.isArray(value) ? value : [],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
// CONNECT_ERROR is server → client only.
|
|
185
|
+
return unsupported(`unexpected socket.io packet type ${type}`, raw);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact-JWE primitives for per-room secrets — a byte-for-byte port of
|
|
3
|
+
* `@ixo/oracles-chain-client`'s `matrix-bot/jwe-utils.ts` (the scheme the
|
|
4
|
+
* portal uses when a user deposits a room secret for an oracle):
|
|
5
|
+
*
|
|
6
|
+
* - key agreement: `ECDH-ES+A256KW` over a P-256 keypair
|
|
7
|
+
* - content encryption: `A256GCM`
|
|
8
|
+
* - serialization: compact JWE
|
|
9
|
+
*
|
|
10
|
+
* Pure `jose` over WebCrypto, so it runs identically on Workers and Node —
|
|
11
|
+
* a secret written by the portal (or the Node runtime) decrypts here and
|
|
12
|
+
* vice versa.
|
|
13
|
+
*/
|
|
14
|
+
import { CompactEncrypt, compactDecrypt, importJWK, type JWK } from 'jose';
|
|
15
|
+
|
|
16
|
+
export type { JWK };
|
|
17
|
+
|
|
18
|
+
const JWE_ALG = 'ECDH-ES+A256KW';
|
|
19
|
+
const JWE_ENC = 'A256GCM';
|
|
20
|
+
|
|
21
|
+
/** Decrypt a compact JWE with the oracle's P-256 private JWK → UTF-8 plaintext. */
|
|
22
|
+
export async function decryptJwe(
|
|
23
|
+
compact: string,
|
|
24
|
+
privateJwk: JWK,
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const key = await importJWK(privateJwk, JWE_ALG);
|
|
27
|
+
const { plaintext } = await compactDecrypt(compact, key);
|
|
28
|
+
return new TextDecoder().decode(plaintext);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compact-JWE encrypt to a P-256 public key (`ECDH-ES+A256KW` / `A256GCM`).
|
|
33
|
+
* Accepts a private JWK too: `d` is stripped so callers holding only the
|
|
34
|
+
* oracle's own keypair can encrypt to themselves (e.g. writing back a
|
|
35
|
+
* refreshed OAuth token) — same contract as the chain-client's `encryptJWE`.
|
|
36
|
+
*/
|
|
37
|
+
export async function encryptJwe(plaintext: string, jwk: JWK): Promise<string> {
|
|
38
|
+
const { d: _d, ...publicJwk } = jwk;
|
|
39
|
+
const key = await importJWK(publicJwk, JWE_ALG);
|
|
40
|
+
return new CompactEncrypt(new TextEncoder().encode(plaintext))
|
|
41
|
+
.setProtectedHeader({ alg: JWE_ALG, enc: JWE_ENC })
|
|
42
|
+
.encrypt(key);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a JSON-encoded EC JWK (as returned by the gateway's
|
|
47
|
+
* `getOracleSecretsKey` — the oracle key is always P-256). Returns `null` on
|
|
48
|
+
* anything malformed so a corrupted key degrades to "no key seated" instead
|
|
49
|
+
* of throwing on the boot path. Unknown fields are dropped.
|
|
50
|
+
*/
|
|
51
|
+
export function parseJwk(json: string): JWK | null {
|
|
52
|
+
let parsed: unknown;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(json);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const { kty, crv, x, y, d, kid } = parsed as Record<string, unknown>;
|
|
62
|
+
if (kty !== 'EC') return null;
|
|
63
|
+
if (
|
|
64
|
+
typeof crv !== 'string' ||
|
|
65
|
+
typeof x !== 'string' ||
|
|
66
|
+
typeof y !== 'string'
|
|
67
|
+
) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const jwk: JWK = { kty: 'EC', crv, x, y };
|
|
71
|
+
if (typeof d === 'string') jwk.d = d;
|
|
72
|
+
if (typeof kid === 'string') jwk.kid = kid;
|
|
73
|
+
return jwk;
|
|
74
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-CBC PIN cipher — the scheme the CLI uses when publishing the
|
|
3
|
+
* oracle's P-256 private JWK into the Matrix account room, ported from the
|
|
4
|
+
* chain-client's `decrypt()` (`setup-claim-signing-mnemonics.ts`):
|
|
5
|
+
*
|
|
6
|
+
* - ciphertext format: `ivHex:cipherHex`
|
|
7
|
+
* - key: the UTF-8 bytes of `pin.padEnd(32)` (no KDF — parity, not choice)
|
|
8
|
+
* - padding: PKCS#7 (Node's `createDecipheriv` default; WebCrypto's
|
|
9
|
+
* AES-CBC removes it identically, so ciphertexts are interchangeable)
|
|
10
|
+
*
|
|
11
|
+
* Bare WebCrypto — runs on Workers and Node alike. Throws on a key that is
|
|
12
|
+
* not exactly 32 bytes, mirroring Node's "Invalid key length" for over-long
|
|
13
|
+
* or multi-byte PINs.
|
|
14
|
+
*/
|
|
15
|
+
import { hexToBytes } from '@noble/hashes/utils';
|
|
16
|
+
|
|
17
|
+
export async function decryptWithPin(
|
|
18
|
+
text: string,
|
|
19
|
+
pin: string,
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const [ivHex, encryptedHex] = text.split(':');
|
|
22
|
+
const iv = hexToBytes(ivHex ?? '');
|
|
23
|
+
const encrypted = hexToBytes(encryptedHex ?? '');
|
|
24
|
+
const keyBytes = new TextEncoder().encode(pin.padEnd(32));
|
|
25
|
+
if (keyBytes.length !== 32) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`decryptWithPin: invalid key length ${keyBytes.length} (the PIN must be at most 32 single-byte characters)`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const key = await crypto.subtle.importKey(
|
|
31
|
+
'raw',
|
|
32
|
+
keyBytes,
|
|
33
|
+
{ name: 'AES-CBC' },
|
|
34
|
+
false,
|
|
35
|
+
['decrypt'],
|
|
36
|
+
);
|
|
37
|
+
const plain = await crypto.subtle.decrypt(
|
|
38
|
+
{ name: 'AES-CBC', iv },
|
|
39
|
+
key,
|
|
40
|
+
encrypted,
|
|
41
|
+
);
|
|
42
|
+
return new TextDecoder().decode(plain);
|
|
43
|
+
}
|