@ixo/oracle-runtime-workers 0.40.1 → 0.41.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.40.1",
3
+ "version": "0.41.1",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -67,10 +67,10 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
+ "@ixo/ucan": "2.3.0",
70
71
  "@ixo/common": "1.8.0",
71
72
  "@ixo/ixo-transaction": "0.2.0",
72
- "@ixo/action-interactions": "0.1.1",
73
- "@ixo/ucan": "2.3.0"
73
+ "@ixo/action-interactions": "0.1.1"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@cloudflare/vitest-pool-workers": "^0.22.0",
package/src/index.ts CHANGED
@@ -54,6 +54,8 @@ export {
54
54
  } from './owner-store/ixo-vfs-store';
55
55
  export { MatrixMediaOwnerStore } from './owner-store/matrix-media-store';
56
56
  export { MatrixGatewayDO } from './matrix/gateway-do';
57
+ /** asks.world sign-ins, one per person; a host re-exports it under this name (`docs/asks.md`). */
58
+ export { AsksConnectionDO } from './plugins/asks/connection-do';
57
59
  export {
58
60
  createUserOracleDO,
59
61
  type OracleWorkerHooks,
@@ -0,0 +1,226 @@
1
+ import {
2
+ defineDecision,
3
+ OraclePlugin,
4
+ type AgentMiddleware,
5
+ type AuthExcludedRoute,
6
+ type MergedConfig,
7
+ type PluginEnv,
8
+ type PluginManifest,
9
+ type PluginRoute,
10
+ type PluginTool,
11
+ type RuntimeContext,
12
+ } from '../../plugin-api';
13
+ import { createMiddleware } from 'langchain';
14
+ import { z } from 'zod';
15
+ import { channelOriginsOf } from './attach';
16
+ import type { AsksWorkerEnv } from './connection';
17
+ import { ASKS_OPERATING_GUIDE } from './guide';
18
+ import { asksMcpUrlOf, deriveKeys, objectNameOf, type AsksKeys } from './oauth';
19
+ import { asksRoutes, connectionOf } from './routes';
20
+ import {
21
+ asksSkillDecisionOptions,
22
+ lastHumanText,
23
+ pickSkill,
24
+ } from './skill-pick';
25
+ import {
26
+ AsksSkills,
27
+ folderOf,
28
+ workersSkillCache,
29
+ type Skill,
30
+ type SkillFolder,
31
+ } from './skills';
32
+ import {
33
+ asksTools,
34
+ connectAsksMcp,
35
+ keepTurnFiles,
36
+ preloadText,
37
+ type McpConnect,
38
+ } from './tools';
39
+
40
+ const configSchema = z.object({
41
+ /** Seals the tokens and names each person's connection object (`openssl rand -base64 32`). */
42
+ ASKS_TOKEN_KEY: z
43
+ .string()
44
+ .min(32, 'ASKS_TOKEN_KEY must be at least 32 characters.'),
45
+ /** `https://wa.me/<number>?text=…`: the callback page's "Back to WhatsApp". */
46
+ ASKS_WHATSAPP_RETURN_URL: z.string().url().optional(),
47
+ /** Pilot: comma-separated user DIDs who get asks. Unset: everyone. */
48
+ ASKS_ALLOWED_DIDS: z.string().optional(),
49
+ });
50
+
51
+ const manifest: PluginManifest = {
52
+ title: 'asks.world',
53
+ summary:
54
+ 'asks.world: find paid work and get paid, post asks real people answer, get an app or agent reviewed by verified pros, and see or cash out asks money.',
55
+ // The on-demand catalogue's line (runtime 0.39+, at most 60 characters).
56
+ brief: 'paid work, asks people answer, pro reviews, asks money',
57
+ whenToUse: [
58
+ 'The person mentions asks.world or an ask, wants paid work or to apply to one, has an invitation to an ask, or wants to hand in an answer or a report.',
59
+ 'The person wants to hire someone, have something checked or found out somewhere they cannot see, or get an app or agent reviewed by pros.',
60
+ 'The person asks about their asks money, earnings or cash-out, their pro page, or whether anyone answered their ask.',
61
+ ],
62
+ whenNotToUse: [
63
+ 'IXO wallet balances, chain transactions or other on-chain actions (asks money is separate).',
64
+ 'General web research or tasks that need no person on asks.world.',
65
+ ],
66
+ tags: ['asks', 'work', 'jobs', 'payments', 'reviews'],
67
+ category: 'integration',
68
+ visibility: 'on-demand',
69
+ stability: 'beta',
70
+ };
71
+
72
+ export interface AsksPluginOptions {
73
+ /** The Worker env: the binding, vars and secret. Default: `env` from `cloudflare:workers`. */
74
+ env?: () => AsksWorkerEnv;
75
+ connect?: McpConnect;
76
+ fetch?: typeof fetch;
77
+ }
78
+
79
+ /** Loaded on first use, so the plain-Node test suites can import the plugin. */
80
+ const workerEnv = async (): Promise<AsksWorkerEnv> => {
81
+ // The host's Env type is its own; the plugin reads only its optional keys.
82
+ const env: object = (await import('cloudflare:workers')).env;
83
+ return env;
84
+ };
85
+
86
+ /**
87
+ * asks.world through its hosted connector (`/v1/mcp` on the asks host for
88
+ * `NETWORK`), signed in once per person with OAuth (asks' Google login).
89
+ * See `docs/asks.md` and `guide.ts` for how the model is told to use it.
90
+ */
91
+ export class AsksPlugin extends OraclePlugin {
92
+ static readonly NAME = 'asks';
93
+
94
+ readonly name = AsksPlugin.NAME;
95
+
96
+ readonly version = '1.0.0';
97
+
98
+ readonly manifest = manifest;
99
+
100
+ override readonly operatingGuide = ASKS_OPERATING_GUIDE;
101
+
102
+ override readonly configSchema = configSchema;
103
+
104
+ override readonly autoDetectHint =
105
+ 'ASKS_TOKEN_KEY, ORACLE_PUBLIC_URL and NETWORK (mainnet, testnet or devnet)';
106
+
107
+ private keys: Promise<AsksKeys> | null = null;
108
+
109
+ private readonly skillSets = new Map<SkillFolder, AsksSkills>();
110
+
111
+ private readonly decision = defineDecision(asksSkillDecisionOptions);
112
+
113
+ constructor(private readonly options: AsksPluginOptions = {}) {
114
+ super();
115
+ }
116
+
117
+ override autoDetect(env: PluginEnv): boolean {
118
+ // The secret too: a deploy before it is set leaves asks off instead of failing the boot.
119
+ const set = ['ASKS_TOKEN_KEY', 'ORACLE_PUBLIC_URL'].every(
120
+ (key) => typeof env[key] === 'string' && env[key].length > 0,
121
+ );
122
+ if (!set) return false;
123
+ if (asksMcpUrlOf(env['NETWORK'])) return true;
124
+ // Never guess a network: the wrong one is the wrong money. No logger here.
125
+ // eslint-disable-next-line no-console
126
+ console.warn(
127
+ `[asks] off: NETWORK is ${typeof env['NETWORK'] === 'string' ? `"${env['NETWORK']}"` : 'not set'}; asks needs mainnet, testnet or devnet`,
128
+ );
129
+ return false;
130
+ }
131
+
132
+ private allowed(config: MergedConfig, did: string): boolean {
133
+ const list = configSchema.parse(config).ASKS_ALLOWED_DIDS;
134
+ return !list?.trim() || list.split(/[\s,]+/).includes(did);
135
+ }
136
+
137
+ private skillsFor(rtCtx: RuntimeContext): Promise<Skill[]> {
138
+ const folder = folderOf(rtCtx.config.NETWORK);
139
+ let set = this.skillSets.get(folder);
140
+ if (!set) {
141
+ set = new AsksSkills({
142
+ folder,
143
+ fetch: (input, init) => (this.options.fetch ?? fetch)(input, init),
144
+ now: () => Date.now(),
145
+ cache: workersSkillCache(),
146
+ warn: (line) => rtCtx.logger.warn(line),
147
+ });
148
+ this.skillSets.set(folder, set);
149
+ }
150
+ return set.skills(rtCtx.background);
151
+ }
152
+
153
+ override async getRequestTools(rtCtx: RuntimeContext): Promise<PluginTool[]> {
154
+ if (!this.allowed(rtCtx.config, rtCtx.user.did)) return [];
155
+ const config = configSchema.parse(rtCtx.config);
156
+ const mcpUrl = asksMcpUrlOf(rtCtx.config.NETWORK);
157
+ if (!mcpUrl) return [];
158
+ const env = this.options.env ? this.options.env() : await workerEnv();
159
+ this.keys ??= deriveKeys(config.ASKS_TOKEN_KEY);
160
+ const objectName = await objectNameOf(await this.keys, rtCtx.user.did);
161
+ const conn = connectionOf(env, objectName);
162
+ if (!conn) {
163
+ rtCtx.logger.warn(
164
+ '[asks] the ASKS_CONNECTIONS binding is missing: no asks tools',
165
+ );
166
+ return [];
167
+ }
168
+ await keepTurnFiles(rtCtx).catch((error: unknown) =>
169
+ rtCtx.logger.warn(
170
+ `[asks] could not keep this turn's files: ${String(error)}`,
171
+ ),
172
+ );
173
+ return asksTools({
174
+ ctx: rtCtx,
175
+ conn,
176
+ mcpUrl,
177
+ channelOrigins: channelOriginsOf(env.CHANNEL_SERVICE_DID),
178
+ skills: await this.skillsFor(rtCtx),
179
+ connect: this.options.connect ?? connectAsksMcp,
180
+ fetch: (input, init) => (this.options.fetch ?? fetch)(input, init),
181
+ now: () => Date.now(),
182
+ });
183
+ }
184
+
185
+ /** With asks loaded for the turn: the skill the message needs, in the prompt before the first model call. */
186
+ override async getRequestMiddlewares(
187
+ rtCtx: RuntimeContext,
188
+ ): Promise<AgentMiddleware[]> {
189
+ if (
190
+ !rtCtx.loadedPlugins.has(this.name) ||
191
+ !this.allowed(rtCtx.config, rtCtx.user.did)
192
+ )
193
+ return [];
194
+ const skills = await this.skillsFor(rtCtx);
195
+ let picked: Promise<Skill | null> | null = null;
196
+ return [
197
+ createMiddleware({
198
+ name: 'AsksSkillPreload',
199
+ wrapModelCall: async (request, handler) => {
200
+ picked ??= pickSkill(
201
+ rtCtx.decisions,
202
+ this.decision,
203
+ lastHumanText(request.messages),
204
+ skills,
205
+ rtCtx.abortSignal,
206
+ );
207
+ const skill = await picked;
208
+ return skill
209
+ ? handler({
210
+ ...request,
211
+ systemMessage: request.systemMessage.concat(preloadText(skill)),
212
+ })
213
+ : handler(request);
214
+ },
215
+ }),
216
+ ];
217
+ }
218
+
219
+ override getRoutes(): PluginRoute[] {
220
+ return asksRoutes();
221
+ }
222
+
223
+ override getAuthExcludedRoutes(): AuthExcludedRoute[] {
224
+ return asksRoutes().map(({ path }) => ({ path, method: 'GET' as const }));
225
+ }
226
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * A file the person sent in the chat, streamed from the channel gateway's
3
+ * media link to the upload address submit_work handed out for an answer
4
+ * draft (asks `workers/src/draftUploads.ts`, #656: a signed R2 PUT per file).
5
+ * The bytes never pass through the model, and neither the media link, the
6
+ * upload address nor the signed URL is ever shown to it.
7
+ */
8
+ import { sha256 } from '@noble/hashes/sha256';
9
+ import { bytesToHex } from '@noble/hashes/utils';
10
+
11
+ /** What asks' draft uploads take: PNG, JPG, WebP or PDF, 5 MB each (10 files, 25 MB a draft). */
12
+ export const UPLOAD_TYPES: Readonly<Record<string, string>> = {
13
+ 'image/png': 'png',
14
+ 'image/jpeg': 'jpg',
15
+ 'image/webp': 'webp',
16
+ 'application/pdf': 'pdf',
17
+ };
18
+ export const MAX_UPLOAD_BYTES = 5_000_000;
19
+
20
+ /** A channel file as the plugin keeps it (the turn's `session.channel.attachments`). */
21
+ export interface KeptFile {
22
+ url: string;
23
+ sha256: string;
24
+ mimetype: string;
25
+ size: number;
26
+ filename: string;
27
+ bindingId: string;
28
+ }
29
+
30
+ /** The https origins of the gateways named in `CHANNEL_SERVICE_DID` (`did:web:<host>`, comma separated). */
31
+ export function channelOriginsOf(value: unknown): string[] {
32
+ if (typeof value !== 'string') return [];
33
+ return value.split(/[\s,]+/).flatMap((did) => {
34
+ const host = /^did:web:([^:]+)/.exec(did)?.[1];
35
+ if (!host) return [];
36
+ try {
37
+ return [new URL(`https://${decodeURIComponent(host)}`).origin];
38
+ } catch {
39
+ return [];
40
+ }
41
+ });
42
+ }
43
+
44
+ /** A file id the way asks names one: letters, digits, `_` and `-`, starting with a letter or digit. */
45
+ export function fileIdOf(name: string): string {
46
+ const id = name
47
+ .replace(/\.[^.]*$/, '')
48
+ .replace(/[^A-Za-z0-9_-]+/g, '-')
49
+ .replace(/^[-_]+|-+$/g, '')
50
+ .slice(0, 64);
51
+ return id || 'file';
52
+ }
53
+
54
+ /** asks' refusal words, with any link taken out. */
55
+ const scrub = (words: string): string =>
56
+ words.replace(/https?:\/\/\S+/g, 'the upload address').slice(0, 300);
57
+
58
+ /* ── asks' upload address (asks #656): sign, PUT to R2, check ───────────────
59
+ * The only code that talks to it. `<address>` is
60
+ * `https://{test.}api.asks.world/draft-uploads/du_…/`:
61
+ * POST <address><name> {type, bytes} → 201 {url, method: "PUT", headers}
62
+ * PUT <url> the bytes, with exactly the signed headers (R2, 10 minutes)
63
+ * GET <address> the server's check → {files, refused, missing}
64
+ * DELETE <address><name>
65
+ */
66
+
67
+ /** A signed PUT asks handed out: always to R2, never anywhere else. */
68
+ export interface SignedPut {
69
+ url: string;
70
+ headers: Record<string, string>;
71
+ }
72
+
73
+ type Refusal = { ok: false; unreachable: boolean; words: string };
74
+
75
+ const isR2 = (url: string): boolean => {
76
+ try {
77
+ const one = new URL(url);
78
+ return (
79
+ one.protocol === 'https:' &&
80
+ !one.username &&
81
+ one.hostname.endsWith('.r2.cloudflarestorage.com')
82
+ );
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+
88
+ async function refusalOf(response: Response): Promise<Refusal> {
89
+ const answer = (await response.json().catch(() => ({}))) as {
90
+ words?: unknown;
91
+ code?: unknown;
92
+ };
93
+ return {
94
+ ok: false,
95
+ unreachable: response.status >= 500,
96
+ words:
97
+ typeof answer.words === 'string'
98
+ ? answer.words
99
+ : `asks.world refused it (${String(answer.code ?? response.status)}).`,
100
+ };
101
+ }
102
+
103
+ /** Ask asks for a signed PUT for one file of `bytes` bytes. */
104
+ export async function signDraftUpload(
105
+ fetcher: typeof fetch,
106
+ address: string,
107
+ name: string,
108
+ type: string,
109
+ bytes: number,
110
+ ): Promise<{ ok: true; put: SignedPut } | Refusal> {
111
+ const response = await fetcher(`${address}${encodeURIComponent(name)}`, {
112
+ method: 'POST',
113
+ headers: { 'content-type': 'application/json' },
114
+ body: JSON.stringify({ type, bytes }),
115
+ }).catch(() => null);
116
+ if (!response)
117
+ return {
118
+ ok: false,
119
+ unreachable: true,
120
+ words: 'asks.world could not be reached.',
121
+ };
122
+ if (!response.ok) return refusalOf(response);
123
+ const body = (await response.json().catch(() => ({}))) as {
124
+ url?: unknown;
125
+ method?: unknown;
126
+ headers?: unknown;
127
+ };
128
+ const headers = body.headers as Record<string, unknown> | undefined;
129
+ if (
130
+ typeof body.url !== 'string' ||
131
+ !isR2(body.url) ||
132
+ (body.method ?? 'PUT') !== 'PUT' ||
133
+ !headers ||
134
+ typeof headers !== 'object' ||
135
+ !Object.values(headers).every((one) => typeof one === 'string')
136
+ )
137
+ return {
138
+ ok: false,
139
+ unreachable: false,
140
+ words: 'asks.world gave an upload link this chat does not use.',
141
+ };
142
+ return {
143
+ ok: true,
144
+ put: { url: body.url, headers: headers as Record<string, string> },
145
+ };
146
+ }
147
+
148
+ /** Stream the bytes to the signed URL, with exactly its headers. */
149
+ export async function putSigned(
150
+ fetcher: typeof fetch,
151
+ put: SignedPut,
152
+ body: ReadableStream<Uint8Array>,
153
+ bytes: number,
154
+ ): Promise<{ ok: true } | Refusal> {
155
+ // Workers send a stream with a Content-Length only through a FixedLengthStream.
156
+ const Fixed = (
157
+ globalThis as {
158
+ FixedLengthStream?: new (
159
+ length: number,
160
+ ) => TransformStream<Uint8Array, Uint8Array>;
161
+ }
162
+ ).FixedLengthStream;
163
+ const response = await fetcher(put.url, {
164
+ method: 'PUT',
165
+ headers: put.headers,
166
+ body: Fixed ? body.pipeThrough(new Fixed(bytes)) : body,
167
+ }).catch(() => null);
168
+ if (!response)
169
+ return {
170
+ ok: false,
171
+ unreachable: true,
172
+ words: 'The upload did not finish.',
173
+ };
174
+ // R2's own error is XML, nothing to show.
175
+ return response.ok
176
+ ? { ok: true }
177
+ : {
178
+ ok: false,
179
+ unreachable: false,
180
+ words: `The upload was refused (${response.status}).`,
181
+ };
182
+ }
183
+
184
+ export async function removeDraftUpload(
185
+ fetcher: typeof fetch,
186
+ address: string,
187
+ name: string,
188
+ ): Promise<void> {
189
+ await fetcher(`${address}${encodeURIComponent(name)}`, {
190
+ method: 'DELETE',
191
+ }).catch(() => undefined);
192
+ }
193
+
194
+ /** asks' own check of what landed: the names it refused and the file ids still missing. */
195
+ export async function checkDraftUploads(
196
+ fetcher: typeof fetch,
197
+ address: string,
198
+ ): Promise<
199
+ | { ok: true; kept: string[]; refused: string[]; missing: string[] }
200
+ | { ok: false }
201
+ > {
202
+ const response = await fetcher(address, {
203
+ headers: { accept: 'application/json' },
204
+ }).catch(() => null);
205
+ if (!response?.ok) return { ok: false };
206
+ const body = (await response.json().catch(() => null)) as {
207
+ files?: unknown;
208
+ refused?: unknown;
209
+ missing?: unknown;
210
+ } | null;
211
+ if (!body) return { ok: false };
212
+ // Names and ids only, and only ones shaped like a file name: nothing else reaches the model.
213
+ const names = (list: unknown, pick: (one: unknown) => unknown): string[] =>
214
+ (Array.isArray(list) ? list : [])
215
+ .map(pick)
216
+ .filter(
217
+ (one): one is string =>
218
+ typeof one === 'string' &&
219
+ /^[A-Za-z0-9][A-Za-z0-9_.-]{0,80}$/.test(one),
220
+ )
221
+ .slice(0, 12);
222
+ const named = (one: unknown) => (one as { name?: unknown } | null)?.name;
223
+ return {
224
+ ok: true,
225
+ kept: names(body.files, named),
226
+ refused: names(body.refused, named),
227
+ missing: names(body.missing, (one) => one),
228
+ };
229
+ }
230
+
231
+ export async function sendFile(input: {
232
+ file: KeptFile;
233
+ /** The file id the answer expects; else one made from the file's name. */
234
+ name?: string;
235
+ address: string;
236
+ origins: readonly string[];
237
+ fetch: typeof fetch;
238
+ }): Promise<{ ok: true; as: string } | { ok: false; why: string }> {
239
+ const { file, address } = input;
240
+ const ext = UPLOAD_TYPES[file.mimetype.toLowerCase()];
241
+ if (!ext)
242
+ return {
243
+ ok: false,
244
+ why: `Asks takes PNG, JPG, WebP or PDF files; this one is ${file.mimetype}.`,
245
+ };
246
+ if (file.size > MAX_UPLOAD_BYTES)
247
+ return {
248
+ ok: false,
249
+ why: 'Asks takes files up to 5 MB; this one is bigger.',
250
+ };
251
+ let source: URL;
252
+ try {
253
+ source = new URL(file.url);
254
+ } catch {
255
+ return { ok: false, why: "The chat's copy of that file can't be read." };
256
+ }
257
+ if (
258
+ !input.origins.includes(source.origin) ||
259
+ !source.pathname.startsWith(`/media/${file.bindingId}/`)
260
+ ) {
261
+ return {
262
+ ok: false,
263
+ why: "That file isn't one the chat gateway keeps, so it was not sent.",
264
+ };
265
+ }
266
+
267
+ const fetched = await input
268
+ .fetch(source.toString(), { redirect: 'manual' })
269
+ .catch(() => null);
270
+ if (!fetched?.ok || !fetched.body) {
271
+ return {
272
+ ok: false,
273
+ why: "The chat's copy of that file can't be read any more (links last 30 days).",
274
+ };
275
+ }
276
+ // asks signs for an exact size: the gateway's own length must agree with it.
277
+ const length = fetched.headers.get('content-length');
278
+ if (length !== null && Number(length) !== file.size) {
279
+ await fetched.body.cancel().catch(() => undefined);
280
+ return {
281
+ ok: false,
282
+ why: 'The file changed on its way from the chat, so it was not attached.',
283
+ };
284
+ }
285
+ const hash = sha256.create();
286
+ let bytes = 0;
287
+ const meter = new TransformStream<Uint8Array, Uint8Array>({
288
+ transform(chunk, controller) {
289
+ bytes += chunk.byteLength;
290
+ if (bytes > file.size) {
291
+ controller.error(new Error('larger than the gateway said'));
292
+ return;
293
+ }
294
+ hash.update(chunk);
295
+ controller.enqueue(chunk);
296
+ },
297
+ });
298
+
299
+ const as = `${input.name ?? fileIdOf(file.filename)}.${ext}`;
300
+ const signed = await signDraftUpload(
301
+ input.fetch,
302
+ address,
303
+ as,
304
+ file.mimetype.toLowerCase(),
305
+ file.size,
306
+ );
307
+ if (!signed.ok) {
308
+ await fetched.body.cancel().catch(() => undefined);
309
+ return {
310
+ ok: false,
311
+ why: signed.unreachable
312
+ ? 'asks.world could not be reached, so the file was not attached. Try again in a minute.'
313
+ : scrub(signed.words),
314
+ };
315
+ }
316
+ const put = await putSigned(
317
+ input.fetch,
318
+ signed.put,
319
+ fetched.body.pipeThrough(meter),
320
+ file.size,
321
+ );
322
+ if (!put.ok && put.unreachable) {
323
+ return bytes > file.size
324
+ ? {
325
+ ok: false,
326
+ why: 'The file changed on its way from the chat, so it was not attached.',
327
+ }
328
+ : {
329
+ ok: false,
330
+ why: 'asks.world could not be reached, so the file was not attached. Try again in a minute.',
331
+ };
332
+ }
333
+ if (!put.ok) return { ok: false, why: scrub(put.words) };
334
+ if (bytes !== file.size || bytesToHex(hash.digest()) !== file.sha256) {
335
+ await removeDraftUpload(input.fetch, address, as);
336
+ return {
337
+ ok: false,
338
+ why: 'The file changed on its way from the chat, so it was taken off again.',
339
+ };
340
+ }
341
+ return { ok: true, as };
342
+ }
@@ -0,0 +1,69 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { AsksConnection, type AsksWorkerEnv, type Surface } from './connection';
3
+ import { asksMcpUrlOf } from './oauth';
4
+
5
+ /**
6
+ * One per person, named HMAC(ASKS_TOKEN_KEY, user DID). Reached from the
7
+ * user's own object (the tools) and from the shell (the sign-in routes), so
8
+ * the tokens live here rather than in either of them. RPC methods only.
9
+ */
10
+ export class AsksConnectionDO extends DurableObject<AsksWorkerEnv> {
11
+ private readonly connection: AsksConnection;
12
+
13
+ constructor(ctx: DurableObjectState, env: AsksWorkerEnv) {
14
+ super(ctx, env);
15
+ const { ORACLE_PUBLIC_URL, ASKS_TOKEN_KEY } = env;
16
+ const mcpUrl = asksMcpUrlOf(env.NETWORK);
17
+ if (!mcpUrl || !ORACLE_PUBLIC_URL || !ASKS_TOKEN_KEY) {
18
+ throw new Error(
19
+ 'asks: NETWORK (mainnet, testnet or devnet), ORACLE_PUBLIC_URL and ASKS_TOKEN_KEY are required',
20
+ );
21
+ }
22
+ this.connection = new AsksConnection({
23
+ storage: ctx.storage,
24
+ config: {
25
+ mcpUrl,
26
+ publicUrl: ORACLE_PUBLIC_URL,
27
+ tokenKey: ASKS_TOKEN_KEY,
28
+ },
29
+ fetch: (input, init) => fetch(input, init),
30
+ now: () => Date.now(),
31
+ });
32
+ }
33
+
34
+ begin(did: string, surface: Surface) {
35
+ return this.connection.begin(did, surface);
36
+ }
37
+
38
+ authorizeUrl(state: string) {
39
+ return this.connection.authorizeUrl(state);
40
+ }
41
+
42
+ complete(state: string, code: string) {
43
+ return this.connection.complete(state, code);
44
+ }
45
+
46
+ status() {
47
+ return this.connection.status();
48
+ }
49
+
50
+ token() {
51
+ return this.connection.token();
52
+ }
53
+
54
+ refused(access: string) {
55
+ return this.connection.refused(access);
56
+ }
57
+
58
+ disconnect() {
59
+ return this.connection.disconnect();
60
+ }
61
+
62
+ keepUpload(draft: string, address: string, expiresAt: number) {
63
+ return this.connection.keepUpload(draft, address, expiresAt);
64
+ }
65
+
66
+ uploadOf(draft: string) {
67
+ return this.connection.uploadOf(draft);
68
+ }
69
+ }