@ixo/oracle-runtime-workers 0.24.0 → 0.25.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.
@@ -762,7 +762,26 @@ export interface RuntimeContext<TConfig = MergedConfig> {
762
762
  provider: string;
763
763
  bindingId: string;
764
764
  bindingRevision: number;
765
- }): Promise<import('@ixo/action-protocol').HubActionOptionsV2>;
765
+ }): Promise<import('@ixo/action-protocol').HubActionOptionsV3>;
766
+ /**
767
+ * A bank send of a token from the owner's options list: `amount` is a
768
+ * decimal in the token's display units; the host converts it to base
769
+ * units from the listed decimals and builds the operation (no fee).
770
+ */
771
+ transfer(input: {
772
+ ownerDid: string;
773
+ sessionId: string;
774
+ runId: string;
775
+ requestId: string;
776
+ provider: string;
777
+ bindingId: string;
778
+ bindingRevision: number;
779
+ requestKey: string;
780
+ toAddress: string;
781
+ denom: string;
782
+ amount: string;
783
+ memo: string;
784
+ }): Promise<ActionReferenceV1>;
766
785
  request(input: {
767
786
  ownerDid: string;
768
787
  sessionId: string;
@@ -774,7 +793,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
774
793
  requestKey: string;
775
794
  operation: ActionOperationV1;
776
795
  }): Promise<ActionReferenceV1>;
777
- status(ownerDid: string, actionId: string): Promise<ActionOutcomeV1 | null>;
796
+ /** The latest authenticated outcome and, for a transfer, its owner-facing line. */
797
+ status(
798
+ ownerDid: string,
799
+ actionId: string,
800
+ ): Promise<{ outcome: ActionOutcomeV1 | null; summary: string | null }>;
778
801
  };
779
802
 
780
803
  decisions: {
@@ -24,9 +24,37 @@ const statusSchema = z.strictObject({
24
24
  actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
25
25
  });
26
26
  const discoverSchema = z.strictObject({});
27
+ const transferSchema = z.strictObject({
28
+ toAddress: z
29
+ .string()
30
+ .min(1)
31
+ .max(100)
32
+ .describe('The recipient address the user gave, exactly.'),
33
+ denom: z
34
+ .string()
35
+ .min(1)
36
+ .max(128)
37
+ .describe(
38
+ 'The token, copied exactly from a `denom` in hub_action_options `transfer.tokens` (never guessed).',
39
+ ),
40
+ amount: z
41
+ .string()
42
+ .regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/)
43
+ .max(80)
44
+ .describe(
45
+ "A decimal amount in the token's listed units (its `decimals`); for an unverified token (decimals 0) a whole number of base units.",
46
+ ),
47
+ memo: z.string().max(256).optional(),
48
+ });
27
49
 
28
50
  export type HubActionRequestResult =
29
- | { status: 'prepared'; actionReference: ActionReferenceV1; summary: string }
51
+ | {
52
+ status: 'prepared';
53
+ actionReference: ActionReferenceV1;
54
+ summary: string;
55
+ /** How the review reaches the user; set on a chat surface only. */
56
+ note?: string;
57
+ }
30
58
  | {
31
59
  status: 'unavailable' | 'expired';
32
60
  reason: string;
@@ -34,6 +62,34 @@ export type HubActionRequestResult =
34
62
  priorActionId?: string;
35
63
  };
36
64
 
65
+ /**
66
+ * What a prepared result tells the model on a chat surface. There the
67
+ * finished run's Reply Plan carries the review as its own `hub-action` part
68
+ * (a card with the title and a review button; its title linked to the review
69
+ * in a plain-text rendering), so text that repeats it reaches the user twice.
70
+ */
71
+ export const HUB_ACTION_CARD_NOTE =
72
+ 'The user is shown this action automatically as a card with its title and a Review action button that opens it in Auth Hub. Do not repeat the link or the title, and do not say that you have not executed or signed anything. Reply with one short line, or nothing.';
73
+
74
+ /**
75
+ * A prepared result. Only a chat turn's reply is a Reply Plan, so only a
76
+ * chat surface gets the note: on the Portal stream no card is added and the
77
+ * model's own text is the whole reply.
78
+ */
79
+ function preparedResult(
80
+ reference: ActionReferenceV1,
81
+ ctx: RuntimeContext,
82
+ ): HubActionRequestResult {
83
+ return {
84
+ status: 'prepared',
85
+ actionReference: reference,
86
+ summary: reference.title,
87
+ ...(ctx.session.surface?.kind === 'chat'
88
+ ? { note: HUB_ACTION_CARD_NOTE }
89
+ : {}),
90
+ };
91
+ }
92
+
37
93
  /**
38
94
  * The idempotency key the Hub sees: a fixed-length digest of the run and
39
95
  * tool call (the Hub allows 120 characters; a run id alone is 72).
@@ -74,6 +130,15 @@ async function requestHubAction(
74
130
  let operation;
75
131
  try {
76
132
  operation = parseOperation(parsed.data.operation);
133
+ if (operation.kind === 'ixo.bank.send.v1')
134
+ // A transfer is built by the runtime from a listed token and a
135
+ // display amount (request_hub_transfer): the model never supplies
136
+ // base units, a denom it chose or a fee.
137
+ return {
138
+ status: 'unavailable',
139
+ reason:
140
+ 'Use request_hub_transfer with a token from hub_action_options for a transfer.',
141
+ };
77
142
  } catch (error) {
78
143
  // The protocol's own words ("Invalid fromAddress"): the model can fix
79
144
  // the field instead of guessing.
@@ -96,28 +161,83 @@ async function requestHubAction(
96
161
  operation,
97
162
  });
98
163
  } catch (error) {
99
- // A cap reached, a request that expired, a run no user message
100
- // started: the runtime's own words, so the model can tell the user.
164
+ // A request that expired, a run no user message started, a Hub
165
+ // refusal: the runtime's own words, so the model can tell the user.
101
166
  // Anything else stays an error.
102
- if (
103
- error instanceof Error &&
104
- error.name === 'HubActionRefused' &&
105
- 'kind' in error
106
- )
107
- return {
108
- status: error.kind === 'expired' ? 'expired' : 'unavailable',
109
- reason: error.message,
110
- ...('priorActionId' in error && typeof error.priorActionId === 'string'
111
- ? { priorActionId: error.priorActionId }
112
- : {}),
113
- };
167
+ const refused = refusalOf(error);
168
+ if (refused) return refused;
114
169
  throw error;
115
170
  }
116
- return {
117
- status: 'prepared',
118
- actionReference: reference,
119
- summary: reference.title,
120
- };
171
+ return preparedResult(reference, ctx);
172
+ }
173
+
174
+ async function requestHubTransfer(
175
+ input: unknown,
176
+ ctx: RuntimeContext,
177
+ ): Promise<HubActionRequestResult> {
178
+ const parsed = transferSchema.safeParse(input);
179
+ if (!parsed.success)
180
+ return {
181
+ status: 'unavailable',
182
+ reason:
183
+ 'A recipient, a listed token denom and a plain decimal amount are required.',
184
+ };
185
+ if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
186
+ return {
187
+ status: 'unavailable',
188
+ reason: 'Hub actions are not enabled for this oracle.',
189
+ };
190
+ const channel = ctx.session.channel;
191
+ if (
192
+ ctx.session.client !== 'channel' ||
193
+ !channel ||
194
+ !ctx.session.runId ||
195
+ !ctx.toolCallId
196
+ )
197
+ return {
198
+ status: 'unavailable',
199
+ reason: 'A verified channel turn is required.',
200
+ };
201
+ try {
202
+ const reference = await ctx.hubActions.transfer({
203
+ ownerDid: ctx.user.did,
204
+ sessionId: ctx.session.id,
205
+ runId: ctx.session.runId,
206
+ requestId: ctx.session.requestId,
207
+ provider: channel.provider,
208
+ bindingId: channel.bindingId,
209
+ bindingRevision: channel.bindingRevision,
210
+ requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
211
+ toAddress: parsed.data.toAddress,
212
+ denom: parsed.data.denom,
213
+ amount: parsed.data.amount,
214
+ memo: parsed.data.memo ?? '',
215
+ });
216
+ return preparedResult(reference, ctx);
217
+ } catch (error) {
218
+ // The owner object turns the protocol refusing the operation built
219
+ // from the model's fields into a refusal; anything else is an error.
220
+ const refused = refusalOf(error);
221
+ if (refused) return refused;
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ /** A typed runtime refusal as a result the model can relay, else null. */
227
+ function refusalOf(error: unknown): HubActionRequestResult | null {
228
+ if (
229
+ error instanceof Error &&
230
+ error.name === 'HubActionRefused' &&
231
+ 'kind' in error
232
+ )
233
+ return {
234
+ status: error.kind === 'expired' ? 'expired' : 'unavailable',
235
+ reason: error.message,
236
+ ...('priorActionId' in error && typeof error.priorActionId === 'string'
237
+ ? { priorActionId: error.priorActionId }
238
+ : {}),
239
+ };
240
+ return null;
121
241
  }
122
242
 
123
243
  async function hubActionStatus(
@@ -131,10 +251,11 @@ async function hubActionStatus(
131
251
  ctx.config.HUB_ACTIONS_ENABLED !== 'true'
132
252
  )
133
253
  return { status: 'unavailable' };
134
- return {
135
- status: 'known',
136
- outcome: await ctx.hubActions.status(ctx.user.did, parsed.data.actionId),
137
- };
254
+ const { outcome, summary } = await ctx.hubActions.status(
255
+ ctx.user.did,
256
+ parsed.data.actionId,
257
+ );
258
+ return { status: 'known', outcome, ...(summary ? { summary } : {}) };
138
259
  }
139
260
 
140
261
  async function hubActionCapabilities(
@@ -218,7 +339,7 @@ export class HubActionsPlugin extends OraclePlugin {
218
339
  {
219
340
  name: 'hub_action_options',
220
341
  description:
221
- 'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents and action resources. Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending.',
342
+ 'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units.',
222
343
  schema: discoverSchema,
223
344
  effect: 'read',
224
345
  handler: hubActionOptions,
@@ -231,6 +352,14 @@ export class HubActionsPlugin extends OraclePlugin {
231
352
  effect: 'write',
232
353
  handler: requestHubAction,
233
354
  },
355
+ {
356
+ name: 'request_hub_transfer',
357
+ description:
358
+ 'Prepare a bank transfer of one of the owner’s listed tokens for review in Auth Hub. Never signs or executes. Read hub_action_options first and copy the token `denom` exactly; give the amount as the user said it in that token’s units (no base-unit math, no fee: Auth Hub sets the fee). Name the full denom for anything but IXO, and say when a token is unverified.',
359
+ schema: transferSchema,
360
+ effect: 'write',
361
+ handler: requestHubTransfer,
362
+ },
234
363
  {
235
364
  name: 'hub_action_status',
236
365
  description:
package/src/shell/app.ts CHANGED
@@ -78,22 +78,7 @@ import {
78
78
  } from '../feedback/submit';
79
79
  import { z } from 'zod';
80
80
  import { parseOutcome } from '@ixo/action-protocol';
81
-
82
- async function equalOutcomeKeys(
83
- provided: string | null,
84
- expected: string | undefined,
85
- ): Promise<boolean> {
86
- if (!provided || !expected || expected.length < 32 || provided.length > 512)
87
- return false;
88
- const digest = async (value: string) =>
89
- new Uint8Array(
90
- await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)),
91
- );
92
- const [a, b] = await Promise.all([digest(provided), digest(expected)]);
93
- let difference = 0;
94
- for (let i = 0; i < a.length; i++) difference |= (a[i] ?? 0) ^ (b[i] ?? 0);
95
- return difference === 0;
96
- }
81
+ import { verifyHubOutcomeCallback } from '../hub-actions/outcome-signature';
97
82
 
98
83
  const DebugEventBody = z.object({
99
84
  type: z
@@ -217,6 +202,22 @@ export const BYO_LLM_BODY_BYTES = 64 * 1024;
217
202
  export const DEBUG_BODY_BYTES = 64 * 1024;
218
203
 
219
204
  /** A body cap enforced before the body is buffered: over it, 413 and the handler never runs. */
205
+ /**
206
+ * The audience a signed Hub outcome callback must be signed for: the origin
207
+ * of `ORACLE_PUBLIC_URL` when it is set, else the origin of the request URL.
208
+ * An unparsable public URL yields '' (which never verifies).
209
+ */
210
+ export function callbackAudience(
211
+ publicUrl: string | undefined,
212
+ requestUrl: string,
213
+ ): string {
214
+ try {
215
+ return new URL(publicUrl || requestUrl).origin;
216
+ } catch {
217
+ return '';
218
+ }
219
+ }
220
+
220
221
  function limitBody(maxSize: number) {
221
222
  return bodyLimit({
222
223
  maxSize,
@@ -455,17 +456,32 @@ export function createShell(
455
456
  });
456
457
 
457
458
  app.use('/api/internal/actions/outcomes', limitBody(64 * 1024));
459
+ // Auth Hub's signed outcome callbacks: on whenever Hub actions are
460
+ // configured. The signature is checked against the Hub's own did:web key
461
+ // (from AUTH_HUB_URL) for this oracle's origin, then the owner object
462
+ // confirms the outcome through Auth Hub's authenticated status API.
458
463
  app.post('/api/internal/actions/outcomes', async (c) => {
459
464
  if (
460
465
  c.env.HUB_ACTIONS_ENABLED !== 'true' ||
461
- !(await equalOutcomeKeys(
462
- c.req.header('x-auth-hub-outcome-key') ?? null,
463
- c.env.AUTH_HUB_ACTION_OUTCOME_KEY,
464
- ))
466
+ !c.env.AUTH_HUB_URL ||
467
+ !c.env.AUTH_HUB_CHANNEL_SERVICE_KEY
465
468
  )
466
469
  return c.json({ code: 'unauthorized' }, 401);
467
470
  const body = await c.req.raw.text();
468
471
  if (body.length > 64_000) return c.json({ code: 'request_too_large' }, 413);
472
+ if (
473
+ !(await verifyHubOutcomeCallback({
474
+ hubUrl: c.env.AUTH_HUB_URL,
475
+ rawBody: body,
476
+ headers: c.req.raw.headers,
477
+ // The origin the Hub signs for: the configured public URL when
478
+ // set (a Worker reachable under several hostnames), else the
479
+ // origin this request came in on.
480
+ audience: callbackAudience(c.env.ORACLE_PUBLIC_URL, c.req.url),
481
+ ...(c.env.AUTH_HUB ? { fetcher: c.env.AUTH_HUB } : {}),
482
+ }))
483
+ )
484
+ return c.json({ code: 'unauthorized' }, 401);
469
485
  try {
470
486
  const outcome = parseOutcome(JSON.parse(body));
471
487
  await userStub(c.env, outcome.ownerDid).recordHubActionOutcome(outcome);