@ixo/oracle-runtime-workers 0.39.0 → 0.41.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/package.json +3 -2
- package/src/action-interactions/routes.ts +78 -0
- package/src/action-interactions/store.ts +1038 -0
- package/src/core/middlewares/tool-repetition-guard.ts +61 -1
- package/src/do/contracts.ts +32 -0
- package/src/do/user-oracle-do.ts +226 -3
- package/src/index.ts +2 -0
- package/src/plugins/asks/asks.plugin.ts +226 -0
- package/src/plugins/asks/attach.ts +342 -0
- package/src/plugins/asks/connection-do.ts +69 -0
- package/src/plugins/asks/connection.ts +368 -0
- package/src/plugins/asks/guide.ts +18 -0
- package/src/plugins/asks/index.ts +5 -0
- package/src/plugins/asks/oauth.ts +381 -0
- package/src/plugins/asks/routes.ts +258 -0
- package/src/plugins/asks/skill-pick.ts +114 -0
- package/src/plugins/asks/skills.bundled.json +27 -0
- package/src/plugins/asks/skills.ts +207 -0
- package/src/plugins/asks/testing.ts +147 -0
- package/src/plugins/asks/tools.ts +714 -0
- package/src/plugins/index.ts +8 -0
- package/src/plugins/portal/portal.plugin.ts +2 -1
- package/src/plugins/tasks/tasks-tools.ts +20 -20
- package/src/shell/app.ts +241 -6
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth 2.1 client side of asks.world's connector (RFC 6749 + PKCE,
|
|
3
|
+
* RFC 8707 resource, RFC 9207 `iss`, RFC 7009 revocation). The oracle is a
|
|
4
|
+
* public client named by a Client ID Metadata Document it serves itself
|
|
5
|
+
* (`/asks/oauth/client.json`), so there is no registration state to keep.
|
|
6
|
+
* Pure helpers over WebCrypto and `fetch`: no Worker bindings here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
export const SCOPE = 'asks';
|
|
12
|
+
|
|
13
|
+
export function b64url(bytes: Uint8Array): string {
|
|
14
|
+
let binary = '';
|
|
15
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
16
|
+
return btoa(binary)
|
|
17
|
+
.replace(/\+/g, '-')
|
|
18
|
+
.replace(/\//g, '_')
|
|
19
|
+
.replace(/=+$/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fromB64url(text: string): Uint8Array {
|
|
23
|
+
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
|
|
24
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const randomToken = (bytes = 32): string =>
|
|
28
|
+
b64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
29
|
+
|
|
30
|
+
export async function pkceChallenge(verifier: string): Promise<string> {
|
|
31
|
+
const digest = await crypto.subtle.digest(
|
|
32
|
+
'SHA-256',
|
|
33
|
+
encoder.encode(verifier),
|
|
34
|
+
);
|
|
35
|
+
return b64url(new Uint8Array(digest));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** asks.world's hosted connector on each IXO network; the runtime's `NETWORK` picks one. */
|
|
39
|
+
export const ASKS_MCP_URLS = {
|
|
40
|
+
mainnet: 'https://asks.world/v1/mcp',
|
|
41
|
+
testnet: 'https://test.asks.world/v1/mcp',
|
|
42
|
+
devnet: 'https://dev.asks.world/v1/mcp',
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/** The connector for `NETWORK`, or null when it is missing or not an IXO network. */
|
|
46
|
+
export function asksMcpUrlOf(network: unknown): string | null {
|
|
47
|
+
return network === 'mainnet' || network === 'testnet' || network === 'devnet'
|
|
48
|
+
? ASKS_MCP_URLS[network]
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where everything lives, from the connector URL and the oracle's own origin. */
|
|
53
|
+
export interface AsksEndpoints {
|
|
54
|
+
/** asks' issuer: the connector's origin (its RFC 8414 metadata says so). */
|
|
55
|
+
issuer: string;
|
|
56
|
+
authorize: string;
|
|
57
|
+
token: string;
|
|
58
|
+
revoke: string;
|
|
59
|
+
/** The one protected resource, `<origin>/v1/mcp`. */
|
|
60
|
+
resource: string;
|
|
61
|
+
clientId: string;
|
|
62
|
+
redirectUri: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function endpointsOf(mcpUrl: string, publicUrl: string): AsksEndpoints {
|
|
66
|
+
const mcp = new URL(mcpUrl);
|
|
67
|
+
const self = publicUrl.replace(/\/+$/, '');
|
|
68
|
+
return {
|
|
69
|
+
issuer: mcp.origin,
|
|
70
|
+
authorize: `${mcp.origin}/oauth/authorize`,
|
|
71
|
+
token: `${mcp.origin}/oauth/token`,
|
|
72
|
+
revoke: `${mcp.origin}/oauth/revoke`,
|
|
73
|
+
resource: `${mcp.origin}${mcp.pathname.replace(/\/+$/, '')}`,
|
|
74
|
+
clientId: `${self}/asks/oauth/client.json`,
|
|
75
|
+
redirectUri: `${self}/asks/oauth/callback`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The Client ID Metadata Document asks fetches (JSON, under 5 kB, no redirect). */
|
|
80
|
+
export function clientDocument(endpoints: AsksEndpoints, name: string) {
|
|
81
|
+
return {
|
|
82
|
+
client_id: endpoints.clientId,
|
|
83
|
+
client_name: name,
|
|
84
|
+
redirect_uris: [endpoints.redirectUri],
|
|
85
|
+
token_endpoint_auth_method: 'none',
|
|
86
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
87
|
+
response_types: ['code'],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function authorizeUrl(
|
|
92
|
+
endpoints: AsksEndpoints,
|
|
93
|
+
state: string,
|
|
94
|
+
challenge: string,
|
|
95
|
+
): string {
|
|
96
|
+
const url = new URL(endpoints.authorize);
|
|
97
|
+
url.search = new URLSearchParams({
|
|
98
|
+
response_type: 'code',
|
|
99
|
+
client_id: endpoints.clientId,
|
|
100
|
+
redirect_uri: endpoints.redirectUri,
|
|
101
|
+
state,
|
|
102
|
+
code_challenge: challenge,
|
|
103
|
+
code_challenge_method: 'S256',
|
|
104
|
+
resource: endpoints.resource,
|
|
105
|
+
scope: SCOPE,
|
|
106
|
+
}).toString();
|
|
107
|
+
return url.toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface Tokens {
|
|
111
|
+
access: string;
|
|
112
|
+
refresh: string;
|
|
113
|
+
/** Epoch ms when the access token stops working. */
|
|
114
|
+
expiresAt: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type TokenAnswer =
|
|
118
|
+
| { ok: true; tokens: Tokens }
|
|
119
|
+
/**
|
|
120
|
+
* `ended`: asks refused the request itself (a 4xx: `invalid_grant`,
|
|
121
|
+
* `invalid_client`, ...), so the person signs in again. Not ended: no
|
|
122
|
+
* answer, a timeout, a 5xx, 408 or 429; worth one retry.
|
|
123
|
+
*/
|
|
124
|
+
| { ok: false; ended: boolean; error: string };
|
|
125
|
+
|
|
126
|
+
/** Each call to asks' OAuth endpoints gives up after this long. */
|
|
127
|
+
export const OAUTH_TIMEOUT_MS = 10_000;
|
|
128
|
+
|
|
129
|
+
/** POST to the token endpoint; never throws. */
|
|
130
|
+
export async function postToken(
|
|
131
|
+
fetcher: typeof fetch,
|
|
132
|
+
endpoints: AsksEndpoints,
|
|
133
|
+
form: Record<string, string>,
|
|
134
|
+
now: number,
|
|
135
|
+
): Promise<TokenAnswer> {
|
|
136
|
+
let response: Response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetcher(endpoints.token, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
142
|
+
accept: 'application/json',
|
|
143
|
+
},
|
|
144
|
+
body: new URLSearchParams({
|
|
145
|
+
...form,
|
|
146
|
+
client_id: endpoints.clientId,
|
|
147
|
+
resource: endpoints.resource,
|
|
148
|
+
}).toString(),
|
|
149
|
+
signal: AbortSignal.timeout(OAUTH_TIMEOUT_MS),
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { ok: false, ended: false, error: String(error) };
|
|
153
|
+
}
|
|
154
|
+
const body = (await response.json().catch(() => ({}))) as Record<
|
|
155
|
+
string,
|
|
156
|
+
unknown
|
|
157
|
+
>;
|
|
158
|
+
if (
|
|
159
|
+
response.ok &&
|
|
160
|
+
typeof body.access_token === 'string' &&
|
|
161
|
+
typeof body.refresh_token === 'string'
|
|
162
|
+
) {
|
|
163
|
+
const seconds = typeof body.expires_in === 'number' ? body.expires_in : 0;
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
tokens: {
|
|
167
|
+
access: body.access_token,
|
|
168
|
+
refresh: body.refresh_token,
|
|
169
|
+
expiresAt: now + seconds * 1000,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const error =
|
|
174
|
+
typeof body.error === 'string' ? body.error : `http_${response.status}`;
|
|
175
|
+
const { status } = response;
|
|
176
|
+
const ended =
|
|
177
|
+
status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
178
|
+
return { ok: false, ended, error };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The IXO account address a `did:ixo:<ixo1…>` DID names, or null. */
|
|
182
|
+
export function addressOfDid(did: string): string | null {
|
|
183
|
+
return /^did:ixo:(ixo1[0-9a-z]{20,90})$/.exec(did)?.[1] ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `ixo1abcdef…wxyz`, for the chat. */
|
|
187
|
+
export const shortAccount = (address: string): string =>
|
|
188
|
+
address.length > 16
|
|
189
|
+
? `${address.slice(0, 10)}…${address.slice(-4)}`
|
|
190
|
+
: address;
|
|
191
|
+
|
|
192
|
+
export type AccountRead =
|
|
193
|
+
| { kind: 'ok'; address: string }
|
|
194
|
+
/** asks answered 401: the token is not (or no longer) good. */
|
|
195
|
+
| { kind: 'refused' }
|
|
196
|
+
/** No answer, a timeout, a 5xx, or an answer that names no account. */
|
|
197
|
+
| { kind: 'unreachable' };
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Which asks account an access token acts for: an MCP `initialize` on the
|
|
201
|
+
* connector, whose instructions open with "Signed in as <ixo1…>:"
|
|
202
|
+
* (asks `services/openhands-mcp/src/server.ts`). Stateless and with no side
|
|
203
|
+
* effect there (unlike `my_status`, which marks notices read).
|
|
204
|
+
* shortcut: reads the address out of asks' instructions text; switch to a
|
|
205
|
+
* structured field once asks names the account in one.
|
|
206
|
+
*/
|
|
207
|
+
export async function accountOf(
|
|
208
|
+
fetcher: typeof fetch,
|
|
209
|
+
endpoints: AsksEndpoints,
|
|
210
|
+
access: string,
|
|
211
|
+
): Promise<AccountRead> {
|
|
212
|
+
let response: Response;
|
|
213
|
+
try {
|
|
214
|
+
response = await fetcher(endpoints.resource, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: {
|
|
217
|
+
authorization: `Bearer ${access}`,
|
|
218
|
+
'content-type': 'application/json',
|
|
219
|
+
accept: 'application/json, text/event-stream',
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
jsonrpc: '2.0',
|
|
223
|
+
id: 1,
|
|
224
|
+
method: 'initialize',
|
|
225
|
+
params: {
|
|
226
|
+
protocolVersion: '2025-06-18',
|
|
227
|
+
capabilities: {},
|
|
228
|
+
clientInfo: { name: 'ixo-oracle', version: '1.0.0' },
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
signal: AbortSignal.timeout(OAUTH_TIMEOUT_MS),
|
|
232
|
+
});
|
|
233
|
+
} catch {
|
|
234
|
+
return { kind: 'unreachable' };
|
|
235
|
+
}
|
|
236
|
+
if (response.status === 401) return { kind: 'refused' };
|
|
237
|
+
const body = (await response.json().catch(() => null)) as {
|
|
238
|
+
result?: { instructions?: unknown };
|
|
239
|
+
} | null;
|
|
240
|
+
const instructions = body?.result?.instructions;
|
|
241
|
+
const address =
|
|
242
|
+
response.ok && typeof instructions === 'string'
|
|
243
|
+
? /^Signed in as (ixo1[0-9a-z]{20,90}):/.exec(instructions)?.[1]
|
|
244
|
+
: undefined;
|
|
245
|
+
return address ? { kind: 'ok', address } : { kind: 'unreachable' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* RFC 7009: best effort, asks answers 200 whether or not it knew the token.
|
|
250
|
+
* At asks either token of a connection ends the whole connection (both
|
|
251
|
+
* tokens), so the refresh token alone is enough.
|
|
252
|
+
*/
|
|
253
|
+
export async function revokeToken(
|
|
254
|
+
fetcher: typeof fetch,
|
|
255
|
+
endpoints: AsksEndpoints,
|
|
256
|
+
token: string,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
await fetcher(endpoints.revoke, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
261
|
+
body: new URLSearchParams({
|
|
262
|
+
token,
|
|
263
|
+
token_type_hint: 'refresh_token',
|
|
264
|
+
client_id: endpoints.clientId,
|
|
265
|
+
}).toString(),
|
|
266
|
+
signal: AbortSignal.timeout(OAUTH_TIMEOUT_MS),
|
|
267
|
+
}).catch(() => undefined);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ── keys: one ASKS_TOKEN_KEY, two derived keys ─────────────────────────── */
|
|
271
|
+
|
|
272
|
+
export interface AsksKeys {
|
|
273
|
+
/** AES-GCM key for what the connection object stores. */
|
|
274
|
+
seal: CryptoKey;
|
|
275
|
+
/** HMAC key naming each person's connection object. */
|
|
276
|
+
name: CryptoKey;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function deriveKeys(secret: string): Promise<AsksKeys> {
|
|
280
|
+
const base = await crypto.subtle.importKey(
|
|
281
|
+
'raw',
|
|
282
|
+
encoder.encode(secret),
|
|
283
|
+
'HKDF',
|
|
284
|
+
false,
|
|
285
|
+
['deriveKey'],
|
|
286
|
+
);
|
|
287
|
+
const params = (info: string) => ({
|
|
288
|
+
name: 'HKDF',
|
|
289
|
+
hash: 'SHA-256',
|
|
290
|
+
salt: new Uint8Array(0),
|
|
291
|
+
info: encoder.encode(info),
|
|
292
|
+
});
|
|
293
|
+
const [seal, name] = await Promise.all([
|
|
294
|
+
crypto.subtle.deriveKey(
|
|
295
|
+
params('asks/tokens'),
|
|
296
|
+
base,
|
|
297
|
+
{ name: 'AES-GCM', length: 256 },
|
|
298
|
+
false,
|
|
299
|
+
['encrypt', 'decrypt'],
|
|
300
|
+
),
|
|
301
|
+
crypto.subtle.deriveKey(
|
|
302
|
+
params('asks/object-name'),
|
|
303
|
+
base,
|
|
304
|
+
{ name: 'HMAC', hash: 'SHA-256', length: 256 },
|
|
305
|
+
false,
|
|
306
|
+
['sign'],
|
|
307
|
+
),
|
|
308
|
+
]);
|
|
309
|
+
return { seal, name };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The connection object's name for a person: HMAC(key, did), 64 hex. */
|
|
313
|
+
export async function objectNameOf(
|
|
314
|
+
keys: AsksKeys,
|
|
315
|
+
userDid: string,
|
|
316
|
+
): Promise<string> {
|
|
317
|
+
const mac = await crypto.subtle.sign(
|
|
318
|
+
'HMAC',
|
|
319
|
+
keys.name,
|
|
320
|
+
encoder.encode(userDid),
|
|
321
|
+
);
|
|
322
|
+
return [...new Uint8Array(mac)]
|
|
323
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
324
|
+
.join('');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export const OBJECT_NAME = /^[0-9a-f]{64}$/;
|
|
328
|
+
|
|
329
|
+
/** The object a sign-in belongs to, from its `state` (`<object name>.<random>`). */
|
|
330
|
+
export function objectNameOfState(state: string): string | null {
|
|
331
|
+
const [name, rest] = state.split('.');
|
|
332
|
+
return name &&
|
|
333
|
+
rest &&
|
|
334
|
+
OBJECT_NAME.test(name) &&
|
|
335
|
+
/^[A-Za-z0-9_-]{32,}$/.test(rest)
|
|
336
|
+
? name
|
|
337
|
+
: null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface Sealed {
|
|
341
|
+
iv: string;
|
|
342
|
+
data: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** AES-GCM with a fresh IV; `kind` is bound as associated data. */
|
|
346
|
+
export async function seal(
|
|
347
|
+
key: CryptoKey,
|
|
348
|
+
kind: string,
|
|
349
|
+
value: unknown,
|
|
350
|
+
): Promise<Sealed> {
|
|
351
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
352
|
+
const data = await crypto.subtle.encrypt(
|
|
353
|
+
{ name: 'AES-GCM', iv, additionalData: encoder.encode(kind) },
|
|
354
|
+
key,
|
|
355
|
+
encoder.encode(JSON.stringify(value)),
|
|
356
|
+
);
|
|
357
|
+
return { iv: b64url(iv), data: b64url(new Uint8Array(data)) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The sealed value, or null when it does not open under this key and kind. */
|
|
361
|
+
export async function unseal<T>(
|
|
362
|
+
key: CryptoKey,
|
|
363
|
+
kind: string,
|
|
364
|
+
sealed: Sealed | undefined,
|
|
365
|
+
): Promise<T | null> {
|
|
366
|
+
if (!sealed) return null;
|
|
367
|
+
try {
|
|
368
|
+
const plain = await crypto.subtle.decrypt(
|
|
369
|
+
{
|
|
370
|
+
name: 'AES-GCM',
|
|
371
|
+
iv: fromB64url(sealed.iv),
|
|
372
|
+
additionalData: encoder.encode(kind),
|
|
373
|
+
},
|
|
374
|
+
key,
|
|
375
|
+
fromB64url(sealed.data),
|
|
376
|
+
);
|
|
377
|
+
return JSON.parse(new TextDecoder().decode(plain)) as T;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sign-in routes, served by the shell Worker without UCAN auth:
|
|
3
|
+
*
|
|
4
|
+
* GET /asks/oauth/client.json the Client ID Metadata Document asks reads
|
|
5
|
+
* GET /asks/oauth/start/<state> the short link sent in the chat → 302 to asks
|
|
6
|
+
* GET /asks/oauth/callback asks sends the browser back here with a code
|
|
7
|
+
*
|
|
8
|
+
* The state names the person's connection object (`<object name>.<random>`),
|
|
9
|
+
* and that object holds the PKCE verifier and checks the state.
|
|
10
|
+
*/
|
|
11
|
+
import type { PluginRoute } from '../../plugin-api';
|
|
12
|
+
import type { AsksWorkerEnv, ConnectionApi } from './connection';
|
|
13
|
+
import {
|
|
14
|
+
asksMcpUrlOf,
|
|
15
|
+
clientDocument,
|
|
16
|
+
endpointsOf,
|
|
17
|
+
objectNameOfState,
|
|
18
|
+
} from './oauth';
|
|
19
|
+
|
|
20
|
+
export function connectionOf(
|
|
21
|
+
env: AsksWorkerEnv,
|
|
22
|
+
objectName: string,
|
|
23
|
+
): ConnectionApi | null {
|
|
24
|
+
const namespace = env.ASKS_CONNECTIONS;
|
|
25
|
+
if (!namespace) return null;
|
|
26
|
+
return namespace.get(namespace.idFromName(objectName));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const escape = (text: string): string =>
|
|
30
|
+
text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
31
|
+
|
|
32
|
+
const oracleNameOf = (env: AsksWorkerEnv): string =>
|
|
33
|
+
env.ORACLE_NAME?.trim() || 'IXO assistant';
|
|
34
|
+
|
|
35
|
+
/** What one result page says. Every field is plain text and is escaped. */
|
|
36
|
+
export interface PageText {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
title: string;
|
|
39
|
+
line: string;
|
|
40
|
+
/** The next step in words, when there is no link to give. */
|
|
41
|
+
next?: string;
|
|
42
|
+
button?: { label: string; href: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const CHECK = 'M17.5 28.5l7 7 14-14';
|
|
46
|
+
export const CROSS = 'M21 21l14 14M35 21L21 35';
|
|
47
|
+
// The asks.world mark (the letter and its dot), on a 7×5 grid.
|
|
48
|
+
const ASKS_MARK = `<svg viewBox="0 0 7 5" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M5 0V5H4V4.49609C3.58183 4.81083 3.06369 5 2.5 5C1.11929 5 0 3.88071 0 2.5C0 1.11929 1.11929 0 2.5 0H5ZM2.5 1C1.67157 1 1 1.67157 1 2.5C1 3.32843 1.67157 4 2.5 4C3.32843 4 4 3.32843 4 2.5V1H2.5Z"/><circle class="dot" cx="6.25" cy="4.25" r=".75"/></svg>`;
|
|
49
|
+
|
|
50
|
+
// Inline CSS only: the CSP below allows no fonts, scripts or images. Colours follow asks.world's own tokens.
|
|
51
|
+
const STYLE = `:root{color-scheme:light dark;--ground:#f4f5f6;--card:#fff;--edge:rgba(25,26,28,.09);--lift:0 1px 2px rgba(25,26,28,.04),0 8px 24px rgba(25,26,28,.06);--ink:#191a1c;--ink-2:rgba(25,26,28,.66);--ink-3:rgba(25,26,28,.4);--rule:rgba(25,26,28,.1);--on-ink:#fff;--ok:#0e7c43;--ok-soft:rgba(14,124,67,.1);--no:#c4281c;--no-soft:rgba(217,45,32,.09);--brand:#0d8261}
|
|
52
|
+
@media (prefers-color-scheme:dark){:root{--ground:#0b0c0d;--card:#151619;--edge:rgba(236,236,237,.08);--lift:none;--ink:#ececed;--ink-2:rgba(236,236,237,.66);--ink-3:rgba(236,236,237,.36);--rule:rgba(236,236,237,.1);--on-ink:#0e0f10;--ok:#34c07c;--ok-soft:rgba(52,192,124,.14);--no:#f0655c;--no-soft:rgba(240,101,92,.14);--brand:#2aeab3}}
|
|
53
|
+
*{box-sizing:border-box}
|
|
54
|
+
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
|
|
55
|
+
body{margin:0;min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:24px 16px calc(32px + env(safe-area-inset-bottom));background:var(--ground);color:var(--ink);font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
|
56
|
+
main{width:100%;max-width:400px;margin-bottom:6vh}
|
|
57
|
+
.card{background:var(--card);border:1px solid var(--edge);box-shadow:var(--lift);border-radius:20px;padding:36px 24px 24px;text-align:center}
|
|
58
|
+
.mark{display:block;width:64px;height:64px;margin:0 auto 20px}
|
|
59
|
+
.mark circle{fill:var(--tone-soft);transform-origin:center;transform-box:fill-box;animation:pop .5s cubic-bezier(.16,1,.3,1) both}
|
|
60
|
+
.mark path{fill:none;stroke:var(--tone);stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:44;animation:draw .6s cubic-bezier(.16,1,.3,1) .12s both}
|
|
61
|
+
.ok{--tone:var(--ok);--tone-soft:var(--ok-soft)}
|
|
62
|
+
.no{--tone:var(--no);--tone-soft:var(--no-soft)}
|
|
63
|
+
h1{margin:0 0 8px;font-size:1.625rem;line-height:1.2;font-weight:650;letter-spacing:-.02em;text-wrap:balance}
|
|
64
|
+
.line{margin:0;color:var(--ink-2);font-size:1.0625rem;overflow-wrap:anywhere;text-wrap:pretty}
|
|
65
|
+
.next{margin:24px 0 0;padding-top:20px;border-top:1px solid var(--rule);font-weight:600;text-wrap:balance}
|
|
66
|
+
.button{display:flex;align-items:center;justify-content:center;min-height:52px;margin-top:28px;padding:12px 20px;border-radius:12px;background:var(--ink);color:var(--on-ink);font-size:1.0625rem;font-weight:600;text-decoration:none;-webkit-tap-highlight-color:transparent;transition:transform .15s cubic-bezier(.16,1,.3,1),opacity .15s}
|
|
67
|
+
.button:hover{opacity:.88}
|
|
68
|
+
.button:active{transform:scale(.98)}
|
|
69
|
+
.button:focus-visible{outline:3px solid var(--brand);outline-offset:3px}
|
|
70
|
+
.pair{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:20px;font-size:.875rem;color:var(--ink-2)}
|
|
71
|
+
.who{min-width:0;overflow-wrap:anywhere;text-align:right}
|
|
72
|
+
.link{flex:0 0 28px;height:2px;border-radius:1px;background:var(--ok)}
|
|
73
|
+
.no .link{background:repeating-linear-gradient(90deg,var(--ink-3) 0 4px,transparent 4px 8px)}
|
|
74
|
+
.asks{display:inline-flex;align-items:center;gap:6px;flex:none;color:var(--ink);font-weight:600}
|
|
75
|
+
.asks svg{width:21px;height:15px}
|
|
76
|
+
.asks .dot{fill:var(--brand)}
|
|
77
|
+
@media (min-width:640px){main{max-width:440px}.card{padding:44px 40px 32px}h1{font-size:1.875rem}}
|
|
78
|
+
@media (min-width:1200px) and (min-height:800px){body{font-size:18px}main{max-width:520px}.card{padding:56px 48px 40px;border-radius:24px}.mark{width:80px;height:80px;margin-bottom:24px}h1{font-size:2.25rem}.line{font-size:1.1875rem}.pair{font-size:1rem;margin-top:24px}.asks svg{width:25px;height:18px}}
|
|
79
|
+
@keyframes pop{from{transform:scale(.8)}}
|
|
80
|
+
@keyframes draw{from{stroke-dashoffset:44}}
|
|
81
|
+
@media (prefers-reduced-motion:reduce){.mark circle,.mark path{animation:none}}`;
|
|
82
|
+
|
|
83
|
+
/** A result page for the browser the sign-in ran in (often WhatsApp's own). */
|
|
84
|
+
export function page(oracle: string, text: PageText): Response {
|
|
85
|
+
const action = text.button
|
|
86
|
+
? `<a class="button" href="${escape(text.button.href)}">${escape(text.button.label)}</a>`
|
|
87
|
+
: text.next
|
|
88
|
+
? `<p class="next">${escape(text.next)}</p>`
|
|
89
|
+
: '';
|
|
90
|
+
const html = `<!doctype html>
|
|
91
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
92
|
+
<meta name="color-scheme" content="light dark"><meta name="robots" content="noindex">
|
|
93
|
+
<meta name="theme-color" content="#f4f5f6" media="(prefers-color-scheme: light)"><meta name="theme-color" content="#0b0c0d" media="(prefers-color-scheme: dark)">
|
|
94
|
+
<title>${escape(text.title)} · Asks</title>
|
|
95
|
+
<style>${STYLE}</style></head>
|
|
96
|
+
<body class="${text.ok ? 'ok' : 'no'}"><main><section class="card" aria-labelledby="title">
|
|
97
|
+
<svg class="mark" viewBox="0 0 56 56" aria-hidden="true"><circle cx="28" cy="28" r="28"/><path d="${text.ok ? CHECK : CROSS}"/></svg>
|
|
98
|
+
<h1 id="title">${escape(text.title)}</h1>
|
|
99
|
+
<p class="line">${escape(text.line)}</p>
|
|
100
|
+
${action}
|
|
101
|
+
</section>
|
|
102
|
+
<footer class="pair"><span class="who">${escape(oracle)}</span><span class="link" aria-hidden="true"></span><span class="asks">${ASKS_MARK}Asks</span></footer>
|
|
103
|
+
</main></body></html>`;
|
|
104
|
+
return new Response(html, {
|
|
105
|
+
status: 200,
|
|
106
|
+
headers: {
|
|
107
|
+
'content-type': 'text/html; charset=utf-8',
|
|
108
|
+
'cache-control': 'no-store',
|
|
109
|
+
'referrer-policy': 'no-referrer',
|
|
110
|
+
'content-security-policy':
|
|
111
|
+
"default-src 'none'; style-src 'unsafe-inline'",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A page for a sign-in that linked nothing. */
|
|
117
|
+
const failed = (
|
|
118
|
+
env: AsksWorkerEnv,
|
|
119
|
+
title: string,
|
|
120
|
+
line: string,
|
|
121
|
+
next = AGAIN,
|
|
122
|
+
) => page(oracleNameOf(env), { ok: false, title, line, next });
|
|
123
|
+
const AGAIN = 'Ask in your chat for a new sign-in link.';
|
|
124
|
+
const NOT_OURS = 'This link won’t work';
|
|
125
|
+
const EXPIRED = 'This link has expired';
|
|
126
|
+
const USED = 'This link was already used';
|
|
127
|
+
const SOMETHING_WRONG = 'Something went wrong';
|
|
128
|
+
|
|
129
|
+
function configured(env: AsksWorkerEnv) {
|
|
130
|
+
const mcpUrl = asksMcpUrlOf(env.NETWORK);
|
|
131
|
+
return mcpUrl && env.ORACLE_PUBLIC_URL
|
|
132
|
+
? endpointsOf(mcpUrl, env.ORACLE_PUBLIC_URL)
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function handleClientDocument(
|
|
137
|
+
env: AsksWorkerEnv,
|
|
138
|
+
): Promise<Response> {
|
|
139
|
+
const endpoints = configured(env);
|
|
140
|
+
if (!endpoints) return new Response('Not found', { status: 404 });
|
|
141
|
+
return new Response(
|
|
142
|
+
JSON.stringify(clientDocument(endpoints, oracleNameOf(env))),
|
|
143
|
+
{
|
|
144
|
+
headers: {
|
|
145
|
+
'content-type': 'application/json',
|
|
146
|
+
'cache-control': 'public, max-age=3600',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function handleStart(
|
|
153
|
+
request: Request,
|
|
154
|
+
env: AsksWorkerEnv,
|
|
155
|
+
): Promise<Response> {
|
|
156
|
+
// A state is base64url and hex only: nothing to decode.
|
|
157
|
+
const state = new URL(request.url).pathname.split('/').pop() ?? '';
|
|
158
|
+
const name = objectNameOfState(state);
|
|
159
|
+
const connection = name ? connectionOf(env, name) : null;
|
|
160
|
+
if (!connection)
|
|
161
|
+
return failed(env, NOT_OURS, 'It isn’t a sign-in link this chat made.');
|
|
162
|
+
const step = await connection.authorizeUrl(state);
|
|
163
|
+
if ('reason' in step) {
|
|
164
|
+
return step.reason === 'expired'
|
|
165
|
+
? failed(env, EXPIRED, 'Sign-in links last 15 minutes.')
|
|
166
|
+
: failed(env, USED, 'It was used, or a newer link replaced it.');
|
|
167
|
+
}
|
|
168
|
+
return new Response(null, {
|
|
169
|
+
status: 302,
|
|
170
|
+
headers: { location: step.url, 'cache-control': 'no-store' },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function handleCallback(
|
|
175
|
+
request: Request,
|
|
176
|
+
env: AsksWorkerEnv,
|
|
177
|
+
): Promise<Response> {
|
|
178
|
+
const endpoints = configured(env);
|
|
179
|
+
const params = new URL(request.url).searchParams;
|
|
180
|
+
const state = params.get('state') ?? '';
|
|
181
|
+
const name = objectNameOfState(state);
|
|
182
|
+
const connection = name ? connectionOf(env, name) : null;
|
|
183
|
+
if (!endpoints || !connection)
|
|
184
|
+
return failed(env, NOT_OURS, 'This sign-in isn’t one this chat started.');
|
|
185
|
+
// RFC 9207: the answer must come from asks' own issuer.
|
|
186
|
+
if (params.get('iss') !== endpoints.issuer)
|
|
187
|
+
return failed(env, NOT_OURS, 'This sign-in didn’t come from asks.world.');
|
|
188
|
+
if (params.get('error'))
|
|
189
|
+
return failed(
|
|
190
|
+
env,
|
|
191
|
+
'Sign-in wasn’t finished',
|
|
192
|
+
'Nothing was linked.',
|
|
193
|
+
'You can start again from your chat.',
|
|
194
|
+
);
|
|
195
|
+
const code = params.get('code');
|
|
196
|
+
if (!code) return failed(env, SOMETHING_WRONG, 'Asks sent no sign-in code.');
|
|
197
|
+
const done = await connection.complete(state, code);
|
|
198
|
+
const oracle = oracleNameOf(env);
|
|
199
|
+
if (!done.ok) {
|
|
200
|
+
if (done.reason === 'other-account')
|
|
201
|
+
return failed(
|
|
202
|
+
env,
|
|
203
|
+
'That’s a different account',
|
|
204
|
+
`You signed in with a different Asks account from the one ${oracle} uses. Nothing was linked.`,
|
|
205
|
+
'Ask in your chat for a new link, and sign in with the same account.',
|
|
206
|
+
);
|
|
207
|
+
if (done.reason === 'unconfirmed')
|
|
208
|
+
return failed(
|
|
209
|
+
env,
|
|
210
|
+
'We couldn’t check your account',
|
|
211
|
+
'Asks didn’t answer just now. Nothing was linked.',
|
|
212
|
+
'Try again from your chat.',
|
|
213
|
+
);
|
|
214
|
+
return done.reason === 'expired'
|
|
215
|
+
? failed(env, EXPIRED, 'The sign-in took longer than 15 minutes.')
|
|
216
|
+
: done.reason === 'unknown'
|
|
217
|
+
? failed(env, USED, 'It was used, or a newer sign-in was started.')
|
|
218
|
+
: failed(
|
|
219
|
+
env,
|
|
220
|
+
SOMETHING_WRONG,
|
|
221
|
+
'Asks didn’t accept the sign-in. Nothing was linked.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const connected = {
|
|
225
|
+
ok: true,
|
|
226
|
+
title: 'You’re connected',
|
|
227
|
+
line: `${oracle} is now linked to your Asks account.`,
|
|
228
|
+
};
|
|
229
|
+
const back = env.ASKS_WHATSAPP_RETURN_URL;
|
|
230
|
+
return done.surface === 'whatsapp' && back
|
|
231
|
+
? page(oracle, {
|
|
232
|
+
...connected,
|
|
233
|
+
line: `${connected.line} Your message is ready to send in WhatsApp.`,
|
|
234
|
+
button: { label: 'Back to WhatsApp', href: back },
|
|
235
|
+
})
|
|
236
|
+
: page(oracle, {
|
|
237
|
+
...connected,
|
|
238
|
+
next: 'Go back to your chat and carry on.',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const asksRoutes = (): PluginRoute[] => [
|
|
243
|
+
{
|
|
244
|
+
method: 'GET',
|
|
245
|
+
path: '/asks/oauth/client.json',
|
|
246
|
+
handler: (_request, env) => handleClientDocument(env),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
method: 'GET',
|
|
250
|
+
path: '/asks/oauth/start/:state',
|
|
251
|
+
handler: (request, env) => handleStart(request, env),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
method: 'GET',
|
|
255
|
+
path: '/asks/oauth/callback',
|
|
256
|
+
handler: (request, env) => handleCallback(request, env),
|
|
257
|
+
},
|
|
258
|
+
];
|