@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.
@@ -15,11 +15,16 @@ export { HubActionsPlugin } from './hub-actions/hub-actions.plugin';
15
15
  * `createOracleWorker({ plugins })` — plugins whose env keys are absent are
16
16
  * skipped by their `autoDetect`.
17
17
  *
18
+ * asks.world (`AsksPlugin`) self-gates on `ASKS_TOKEN_KEY`, `ORACLE_PUBLIC_URL`
19
+ * and `NETWORK`; the host also binds and exports `AsksConnectionDO`
20
+ * (`docs/asks.md`).
21
+ *
18
22
  * Shipped but opt-in (not in the bundle): `IxoTransactionPlugin`, which asks
19
23
  * the user's Portal wallet to sign chain transactions; an oracle adds
20
24
  * `new IxoTransactionPlugin()` itself.
21
25
  */
22
26
  import type { OraclePlugin } from '../plugin-api/oracle-plugin';
27
+ import { AsksPlugin } from './asks';
23
28
  import { ComposioPlugin } from './composio';
24
29
  import { EditorPlugin } from './editor';
25
30
  import { TasksPlugin } from './tasks'; // pre-constructed object (defineOraclePlugin)
@@ -35,6 +40,7 @@ import { AttachmentsPlugin } from './attachments';
35
40
  import { MatrixGroupChatsPlugin } from './matrix-group-chats';
36
41
  import { PodCreatorPlugin } from './pod-creator';
37
42
 
43
+ export * from './asks';
38
44
  export * from './composio';
39
45
  export * from './editor';
40
46
  export * from './flows';
@@ -64,6 +70,7 @@ export const aguiPlugin = new AGUIPlugin();
64
70
  export const attachmentsPlugin = new AttachmentsPlugin();
65
71
  export const matrixGroupChatsPlugin = new MatrixGroupChatsPlugin();
66
72
  export const podCreatorPlugin = new PodCreatorPlugin();
73
+ export const asksPlugin = new AsksPlugin();
67
74
  /** TasksPlugin is already an object (defineOraclePlugin), not a class. */
68
75
  export const tasksPlugin = TasksPlugin;
69
76
 
@@ -90,4 +97,5 @@ export const BUNDLED_WORKERS_PLUGINS = [
90
97
  attachmentsPlugin,
91
98
  matrixGroupChatsPlugin,
92
99
  podCreatorPlugin,
100
+ asksPlugin,
93
101
  ] as const satisfies readonly OraclePlugin[];
@@ -33,8 +33,9 @@ import {
33
33
  const manifest: PluginManifest = {
34
34
  title: 'Portal',
35
35
  summary:
36
- "Browser-side actions on the user's Portal UI — opens URLs, manipulates the DOM, runs FE-defined browser tools.",
36
+ "The user's Portal UI: action cards (bids, claims, questions, approvals), forms, Topics, domains, pages, navigation.",
37
37
  whenToUse: [
38
+ 'User asks to prepare or submit a bid or claim: call `list_action_cards`, then `prepare_action_card`.',
38
39
  'User asks for an action the Portal FE exposes as a browser tool (declared in `state.browserTools`).',
39
40
  "A task needs a browser-side capability the server can't do alone — open a URL in the user's tab, click a Portal button, fill a form.",
40
41
  ],
@@ -95,6 +95,25 @@ function summarizeRecord(record: OracleTaskRecord): Record<string, unknown> {
95
95
  };
96
96
  }
97
97
 
98
+ /** Current owner summary shared by get_task and private card readback. */
99
+ export function taskCardSummary(
100
+ record: OracleTaskRecord,
101
+ ): Record<string, unknown> {
102
+ return {
103
+ ok: true,
104
+ ...summarizeRecord(record),
105
+ ...(record.executionProfile
106
+ ? { note: RESTRICTED_TASK_NOTE }
107
+ : { intent: record.intent }),
108
+ lastRunAt: record.lastRunAt ?? null,
109
+ lastResult:
110
+ record.executionProfile && record.lastResult
111
+ ? { ok: record.lastResult.ok, at: record.lastResult.at }
112
+ : (record.lastResult ?? null),
113
+ consecutiveFailures: record.consecutiveFailures,
114
+ };
115
+ }
116
+
98
117
  function failure(err: unknown): { ok: false; error: string } {
99
118
  return {
100
119
  ok: false,
@@ -242,26 +261,7 @@ function getTask(): PluginTool {
242
261
  try {
243
262
  const record = await tasks.get(taskId);
244
263
  if (!record) return { ok: false, error: 'Task not found.' };
245
- if (record.executionProfile) {
246
- return {
247
- ok: true,
248
- ...summarizeRecord(record),
249
- lastRunAt: record.lastRunAt ?? null,
250
- lastResult: record.lastResult
251
- ? { ok: record.lastResult.ok, at: record.lastResult.at }
252
- : null,
253
- consecutiveFailures: record.consecutiveFailures,
254
- note: RESTRICTED_TASK_NOTE,
255
- };
256
- }
257
- return {
258
- ok: true,
259
- ...summarizeRecord(record),
260
- intent: record.intent,
261
- lastRunAt: record.lastRunAt ?? null,
262
- lastResult: record.lastResult ?? null,
263
- consecutiveFailures: record.consecutiveFailures,
264
- };
264
+ return taskCardSummary(record);
265
265
  } catch (err) {
266
266
  return failure(err);
267
267
  }
package/src/shell/app.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import {
2
+ ActionInteractionError,
3
+ CheckClaimDispatchSchema,
4
+ CheckDispatchSchema,
5
+ } from '@ixo/action-interactions';
6
+ import { ZodError } from 'zod';
7
+ import {
8
+ interactionStatus,
9
+ parseInteractionCommand,
10
+ parseInteractionRead,
11
+ } from '../action-interactions/routes';
1
12
  import {
2
13
  ActionProposalWireSchema,
3
14
  TopicReceiptReferencesSchema,
@@ -246,6 +257,9 @@ export function rateLimitKey(
246
257
  env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
247
258
  scope:
248
259
  | 'user'
260
+ | 'card-read'
261
+ | 'card-write'
262
+ | 'card-dispatch'
249
263
  | 'poll'
250
264
  | 'channel'
251
265
  | 'channel-control'
@@ -422,6 +436,32 @@ export function createShell(
422
436
  status: 'ok',
423
437
  timestamp: new Date().toISOString(),
424
438
  frontendTools: FRONTEND_BRIDGE,
439
+ ...(c.env.ACTION_INTERACTIONS_ENABLED === 'true'
440
+ ? {
441
+ actionInteractions: {
442
+ version: 1,
443
+ owners: [
444
+ 'flow-claim',
445
+ 'flow-claim-block',
446
+ 'flow-bid',
447
+ 'owner-reference',
448
+ 'approval',
449
+ 'task',
450
+ 'question',
451
+ 'artifact',
452
+ ],
453
+ operations: [
454
+ 'prepare',
455
+ 'read',
456
+ 'update',
457
+ 'reserve',
458
+ 'observe',
459
+ 'dispatch',
460
+ 'read-task',
461
+ ],
462
+ },
463
+ }
464
+ : {}),
425
465
  }),
426
466
  );
427
467
  // A liveness probe: the gateway's in-memory flag, no storage read. The
@@ -688,15 +728,27 @@ export function createShell(
688
728
  if (!limiter) return next();
689
729
  const auth = c.get('auth') as AuthResult | undefined;
690
730
  if (!auth) return next();
691
- // The chat lists' status poll and the card routes (Portal's review
692
- // page) each have their own bucket: polling a card or the run list
693
- // while chatting must not push turns into 429.
731
+ // The chat lists' status poll, the card routes (Portal's review page) and
732
+ // action-card reads, writes and dispatch checks each have their own bucket:
733
+ // polling or typing in a card while chatting must not push turns into 429,
734
+ // and neither chat nor card edits can use up the dispatch check, whose 429
735
+ // the Portal treats as "could not ask" and lets the submission through.
736
+ const isGet = c.req.method === 'GET';
737
+ const path = c.req.path;
738
+ const card = path.startsWith('/action-interactions');
694
739
  const scope =
695
- c.req.method === 'GET' && c.req.path === '/runs/active'
740
+ isGet && path === '/runs/active'
696
741
  ? 'poll'
697
- : c.req.path.startsWith('/action-proposals/')
742
+ : path.startsWith('/action-proposals/')
698
743
  ? 'action'
699
- : 'user';
744
+ : card && isGet
745
+ ? 'card-read'
746
+ : path === '/action-interactions/dispatch' ||
747
+ path === '/action-interactions/claim-dispatch'
748
+ ? 'card-dispatch'
749
+ : card
750
+ ? 'card-write'
751
+ : 'user';
700
752
  const { success } = await limiter.limit({
701
753
  key: rateLimitKey(c.env, scope, auth.userDid),
702
754
  });
@@ -712,6 +764,189 @@ export function createShell(
712
764
  return next();
713
765
  });
714
766
 
767
+ // These routes own private proposals, never action execution. No route is a
768
+ // model tool; host reservation requires the authenticated signed invocation.
769
+ app.use('/action-interactions*', async (c, next) => {
770
+ if (
771
+ c.env.ACTION_INTERACTIONS_ENABLED !== 'true' &&
772
+ c.req.path !== '/action-interactions/claim-dispatch' &&
773
+ c.req.path !== '/action-interactions/dispatch'
774
+ )
775
+ return c.json(
776
+ { code: 'unavailable', message: 'Action interactions are disabled' },
777
+ 404,
778
+ );
779
+ if (c.get('auth')?.via !== 'invocation')
780
+ return c.json(
781
+ {
782
+ code: 'invocation_required',
783
+ message: 'A signed invocation is required',
784
+ },
785
+ 401,
786
+ );
787
+ return next();
788
+ });
789
+ const interactionBodyLimit = (maxSize: number) =>
790
+ bodyLimit({
791
+ maxSize,
792
+ onError: (c) =>
793
+ c.json({ code: 'too_large', message: 'Request is too large' }, 413),
794
+ });
795
+ const smallInteractionBody = interactionBodyLimit(96 * 1024);
796
+ // Only a prepare can carry a domain-write approval (up to 2 MiB).
797
+ const preparedInteractionBody = interactionBodyLimit(2 * 1024 * 1024);
798
+ app.use('/action-interactions*', (c, next) =>
799
+ c.req.method === 'POST' && c.req.path === '/action-interactions'
800
+ ? preparedInteractionBody(c, next)
801
+ : smallInteractionBody(c, next),
802
+ );
803
+ app.get('/action-interactions/tasks/:taskId', async (c) => {
804
+ const sessionId = c.req.query('sessionId');
805
+ if (!sessionId)
806
+ return c.json(
807
+ { code: 'invalid_request', message: 'sessionId is required' },
808
+ 400,
809
+ );
810
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
811
+ const result = await userStub(
812
+ c.env,
813
+ identity.userDid,
814
+ ).readActionInteractionTask(identity, sessionId, c.req.param('taskId'));
815
+ if (result.ok)
816
+ return new Response(result.value, {
817
+ headers: {
818
+ 'content-type': 'application/json',
819
+ 'cache-control': 'no-store',
820
+ },
821
+ });
822
+ return c.json({ code: result.code, message: result.message }, 404);
823
+ });
824
+ app.post('/action-interactions/dispatch', async (c) => {
825
+ const parsed = CheckDispatchSchema.safeParse(
826
+ await c.req.json().catch(() => null),
827
+ );
828
+ if (!parsed.success)
829
+ return c.json(
830
+ { code: 'invalid_request', message: 'Invalid dispatch binding' },
831
+ 400,
832
+ );
833
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
834
+ const result = await userStub(c.env, identity.userDid).checkDispatch(
835
+ identity,
836
+ parsed.data,
837
+ );
838
+ if (result.ok)
839
+ return new Response(result.value, {
840
+ headers: {
841
+ 'content-type': 'application/json',
842
+ 'cache-control': 'no-store',
843
+ },
844
+ });
845
+ return c.json(
846
+ { code: result.code, message: result.message },
847
+ result.status === 403 ? 403 : 409,
848
+ );
849
+ });
850
+ app.post('/action-interactions/claim-dispatch', async (c) => {
851
+ const parsed = CheckClaimDispatchSchema.safeParse(
852
+ await c.req.json().catch(() => null),
853
+ );
854
+ if (!parsed.success)
855
+ return c.json(
856
+ { code: 'invalid_request', message: 'Invalid claim dispatch binding' },
857
+ 400,
858
+ );
859
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
860
+ const result = await userStub(c.env, identity.userDid).checkClaimDispatch(
861
+ identity,
862
+ parsed.data,
863
+ );
864
+ if (result.ok)
865
+ return new Response(result.value, {
866
+ headers: {
867
+ 'content-type': 'application/json',
868
+ 'cache-control': 'no-store',
869
+ },
870
+ });
871
+ return c.json(
872
+ { code: result.code, message: result.message },
873
+ result.status === 403 ? 403 : 409,
874
+ );
875
+ });
876
+ app.on(
877
+ ['GET', 'POST', 'PATCH'],
878
+ [
879
+ '/action-interactions',
880
+ '/action-interactions/:id',
881
+ '/action-interactions/:id/:operation',
882
+ ],
883
+ async (c) => {
884
+ try {
885
+ // Every request is parsed before a user object is addressed.
886
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
887
+ if (c.req.method === 'GET') {
888
+ const read = parseInteractionRead(
889
+ c.req.query('sessionId'),
890
+ c.req.param('id'),
891
+ c.req.param('operation'),
892
+ );
893
+ const result = await userStub(
894
+ c.env,
895
+ identity.userDid,
896
+ ).readActionInteraction(
897
+ identity,
898
+ read.sessionId,
899
+ read.id,
900
+ c.req.query('view') === 'references',
901
+ );
902
+ if (result.ok)
903
+ return new Response(result.value, {
904
+ headers: {
905
+ 'content-type': 'application/json',
906
+ 'cache-control': 'no-store',
907
+ },
908
+ });
909
+ return c.json(
910
+ { code: result.code, message: result.message },
911
+ interactionStatus(result.status),
912
+ );
913
+ }
914
+ const command = await parseInteractionCommand(
915
+ c.req.raw,
916
+ c.req.param('id'),
917
+ c.req.param('operation'),
918
+ );
919
+ const result = await userStub(
920
+ c.env,
921
+ identity.userDid,
922
+ ).actionInteraction(identity, command);
923
+ if (result.ok)
924
+ return new Response(result.value, {
925
+ headers: {
926
+ 'content-type': 'application/json',
927
+ 'cache-control': 'no-store',
928
+ },
929
+ });
930
+ return c.json(
931
+ { code: result.code, message: result.message },
932
+ interactionStatus(result.status),
933
+ );
934
+ } catch (error) {
935
+ if (error instanceof ZodError)
936
+ return c.json(
937
+ { code: 'invalid_request', message: 'Invalid interaction request' },
938
+ 400,
939
+ );
940
+ if (error instanceof ActionInteractionError)
941
+ return c.json(
942
+ { code: error.code, message: error.message },
943
+ interactionStatus(error.status),
944
+ );
945
+ throw error;
946
+ }
947
+ },
948
+ );
949
+
715
950
  // What the owner object answers a proposal command with: the card; a
716
951
  // conflict (the card or the authority it was sealed on changed: the Portal
717
952
  // re-reads and retries, 409); or an authority read that failed and may