@ixo/oracle-runtime-workers 0.22.0 → 0.24.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.
Files changed (46) hide show
  1. package/README.md +15 -13
  2. package/package.json +10 -4
  3. package/src/action-proposals/policy.ts +54 -0
  4. package/src/action-proposals/reconcile.ts +87 -0
  5. package/src/action-proposals/store.ts +863 -0
  6. package/src/action-proposals/topic-budget.ts +84 -0
  7. package/src/action-proposals/topic-chain.ts +272 -0
  8. package/src/action-proposals/topic-effects.ts +44 -0
  9. package/src/action-proposals/topic-errors.ts +79 -0
  10. package/src/action-proposals/topic-history.ts +267 -0
  11. package/src/action-proposals/topic-matrix.ts +292 -0
  12. package/src/action-proposals/topic-prepare.ts +299 -0
  13. package/src/action-proposals/topic-store.ts +999 -0
  14. package/src/action-proposals/topic-verify.ts +279 -0
  15. package/src/action-proposals/verify.ts +223 -0
  16. package/src/channels/auth.ts +6 -1
  17. package/src/channels/contract.ts +4 -0
  18. package/src/channels/turns.ts +1 -0
  19. package/src/core/env.ts +93 -6
  20. package/src/core/main-agent-types.ts +8 -0
  21. package/src/core/main-agent.ts +2 -0
  22. package/src/core/runtime-context.ts +12 -0
  23. package/src/delivery/plan.ts +43 -2
  24. package/src/delivery/schema.ts +25 -1
  25. package/src/delivery/types.ts +13 -2
  26. package/src/do/ambient.ts +6 -0
  27. package/src/do/contracts.ts +39 -0
  28. package/src/do/user-oracle-do.ts +1275 -8
  29. package/src/hub-actions/client.ts +163 -0
  30. package/src/hub-actions/request.ts +110 -0
  31. package/src/hub-actions/store.ts +485 -0
  32. package/src/matrix/gateway-do.ts +176 -0
  33. package/src/matrix/reply-parts.ts +8 -2
  34. package/src/memory/session-history-indexer.ts +30 -3
  35. package/src/memory/user-context-fetcher.ts +36 -12
  36. package/src/plugin-api/types.ts +54 -0
  37. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  38. package/src/plugins/action-proposals/evidence.ts +67 -0
  39. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  40. package/src/plugins/index.ts +6 -0
  41. package/src/plugins/memory/index.ts +4 -0
  42. package/src/plugins/memory/memory-entity.ts +49 -0
  43. package/src/plugins/memory/memory-tools.ts +7 -4
  44. package/src/plugins/memory/memory-ucan.ts +10 -1
  45. package/src/plugins/memory/memory.plugin.ts +22 -7
  46. package/src/shell/app.ts +195 -1
@@ -0,0 +1,279 @@
1
+ import { withTopicReadBudget } from './topic-budget';
2
+ import { canonicalizeIxoJson } from '@ixo/ixo-transaction/action-proposals';
3
+ import {
4
+ digestTopicEffect,
5
+ type TopicActionProposal,
6
+ type ExpectedTopicEffect,
7
+ type TopicReceiptReferences,
8
+ } from '@ixo/ixo-transaction/topic-proposals';
9
+ import { assertCreatePlanUnchanged } from '@ixo/ixo-transaction/topic-create-plan';
10
+ import {
11
+ TopicMatrixEventSchema,
12
+ assertTopicRoomUnchanged,
13
+ readTopicEvent,
14
+ type TopicMatrixReader,
15
+ } from './topic-matrix';
16
+ import { readTopicAuthority } from './topic-history';
17
+ import { TopicChainReader } from './topic-chain';
18
+ import {
19
+ digestTopicProposalEffect,
20
+ type TopicProposalStore,
21
+ } from './topic-store';
22
+ import {
23
+ ActionProposalConflict,
24
+ TRANSACTION_CARD_BOUND_MISMATCH,
25
+ TRANSACTION_REJECTIONS,
26
+ } from './store';
27
+ import { TopicReadUnavailable } from './topic-errors';
28
+ import { verifyTopicGrantTransaction } from './verify';
29
+ export type TopicVerificationContext = {
30
+ reader: TopicMatrixReader;
31
+ blocksync: string | undefined;
32
+ rpc: string | undefined;
33
+ fetcher?: typeof fetch;
34
+ onAttempt?: () => void;
35
+ /** An outer wall budget (the alarm tick's) every read here also obeys. */
36
+ signal?: AbortSignal;
37
+ };
38
+ export async function assertTopicDependencies(
39
+ proposal: TopicActionProposal,
40
+ ctx: TopicVerificationContext,
41
+ ) {
42
+ return withTopicReadBudget(
43
+ ctx.reader,
44
+ ctx.fetcher ?? fetch,
45
+ (reader, fetcher) =>
46
+ assertTopicDependenciesWithinBudget(proposal, {
47
+ ...ctx,
48
+ reader,
49
+ fetcher,
50
+ }),
51
+ ctx.signal,
52
+ );
53
+ }
54
+ async function assertTopicDependenciesWithinBudget(
55
+ proposal: TopicActionProposal,
56
+ ctx: TopicVerificationContext,
57
+ ) {
58
+ const room = await assertTopicRoomUnchanged(
59
+ proposal,
60
+ ctx.reader,
61
+ ctx.blocksync,
62
+ ctx.fetcher,
63
+ );
64
+ if (proposal.action.name === 'create_topic') {
65
+ try {
66
+ await assertCreatePlanUnchanged(proposal.action.plan);
67
+ } catch (error) {
68
+ // The plan builder's own refusals describe the plan's internals; what
69
+ // the owner is told is that the reviewed plan no longer builds the same.
70
+ console.warn(
71
+ `[topic-actions] ${proposal.id} creation plan check: ${error instanceof Error ? error.message : String(error)}`,
72
+ );
73
+ throw new ActionProposalConflict(
74
+ 'The reviewed topic creation plan changed',
75
+ );
76
+ }
77
+ return;
78
+ }
79
+ if (!ctx.rpc)
80
+ throw new TopicReadUnavailable('Trusted chain endpoint unavailable');
81
+ const action = proposal.action;
82
+ // The room state just read is the state the authority is judged on: one
83
+ // state read, not two.
84
+ const topic = await readTopicAuthority(
85
+ ctx.reader,
86
+ action.roomId,
87
+ action.topicId,
88
+ action.owner.matrixUserId,
89
+ room.state,
90
+ );
91
+ const chain = new TopicChainReader(ctx.rpc, action.chainId, ctx.fetcher);
92
+ const collection = await chain.collection(
93
+ action.entityDid,
94
+ action.collectionId,
95
+ action.signerAddress,
96
+ );
97
+ // One authorization query per grant, all at once: the ten second budget
98
+ // has to cover the topic history and the collection reads as well.
99
+ const authorizations = await Promise.all(
100
+ action.grants.map((grant) =>
101
+ chain.authorization(action.adminAddress, grant.address, grant.role),
102
+ ),
103
+ );
104
+ const current: TopicActionProposal['action']['dependencies'] = [
105
+ ...topic.dependencies,
106
+ ...collection.dependencies,
107
+ ...authorizations.map((a) => a.dependency),
108
+ ];
109
+ for (const dep of action.dependencies.filter((d) => d.kind !== 'matrix'))
110
+ if (
111
+ !current.some(
112
+ (d) =>
113
+ d.kind === dep.kind && d.key === dep.key && d.digest === dep.digest,
114
+ )
115
+ )
116
+ // A fact the card was sealed on moved: a conflict the Portal can show
117
+ // and retry after a re-read, not a server fault.
118
+ throw new ActionProposalConflict('Topic or chain authority changed');
119
+ }
120
+ export async function verifyTopicReference(
121
+ proposal: TopicActionProposal,
122
+ effect: ExpectedTopicEffect,
123
+ reference: TopicReceiptReferences['references'][number],
124
+ ctx: TopicVerificationContext,
125
+ ) {
126
+ if (effect.kind === 'chain') {
127
+ if (reference.kind !== 'chain' || !ctx.rpc)
128
+ throw new ActionProposalConflict('Expected a chain reference');
129
+ const verified = await verifyTopicGrantTransaction(
130
+ effect,
131
+ reference.transactionHash,
132
+ ctx.rpc,
133
+ ctx.fetcher,
134
+ );
135
+ return {
136
+ kind: 'chain' as const,
137
+ chainId: effect.chainId,
138
+ ...verified,
139
+ messageDigest: await digestTopicEffect(effect),
140
+ };
141
+ }
142
+ if (reference.kind !== 'matrix')
143
+ throw new ActionProposalConflict('Expected a Matrix reference');
144
+ const event = await readTopicEvent(
145
+ ctx.reader,
146
+ effect.roomId,
147
+ reference.eventId,
148
+ );
149
+ if (
150
+ event.event_id !== reference.eventId ||
151
+ event.room_id !== effect.roomId ||
152
+ event.sender !== effect.sender ||
153
+ event.type !== effect.eventType ||
154
+ event.state_key !== effect.stateKey ||
155
+ canonicalizeIxoJson(event.content) !== canonicalizeIxoJson(effect.content)
156
+ )
157
+ throw new ActionProposalConflict(
158
+ 'Matrix receipt does not match the reviewed effect',
159
+ );
160
+ if (reference.step === 'snapshot') {
161
+ const state = await ctx.reader.state(effect.roomId);
162
+ const { z } = await import('zod');
163
+ const events = z.array(TopicMatrixEventSchema).parse(state);
164
+ if (
165
+ !events.some(
166
+ (e) =>
167
+ e.event_id === event.event_id &&
168
+ e.state_key === effect.stateKey &&
169
+ e.type === effect.eventType,
170
+ )
171
+ )
172
+ throw new ActionProposalConflict(
173
+ 'Topic snapshot was replaced before verification',
174
+ );
175
+ if (proposal.action.name === 'create_topic') {
176
+ const current = await readTopicAuthority(
177
+ ctx.reader,
178
+ effect.roomId,
179
+ proposal.action.plan.identity.topicId,
180
+ effect.sender,
181
+ );
182
+ if (
183
+ current.authority.revision !==
184
+ proposal.action.plan.identity.operationId ||
185
+ current.authority.contractBodyHash !== proposal.action.plan.bodyHash
186
+ )
187
+ throw new ActionProposalConflict(
188
+ 'Created topic is not canonically readable',
189
+ );
190
+ }
191
+ }
192
+ return {
193
+ kind: 'matrix' as const,
194
+ roomId: effect.roomId,
195
+ eventId: event.event_id,
196
+ sender: event.sender,
197
+ eventType: event.type,
198
+ ...(event.state_key !== undefined ? { stateKey: event.state_key } : {}),
199
+ contentDigest: await digestTopicProposalEffect(event.content),
200
+ verifiedAt: new Date().toISOString(),
201
+ };
202
+ }
203
+ export async function reconcileTopicReferences(
204
+ store: TopicProposalStore,
205
+ p: TopicActionProposal,
206
+ input: TopicReceiptReferences,
207
+ ctx: TopicVerificationContext,
208
+ verificationClaimed = false,
209
+ ) {
210
+ let newReference = false;
211
+ for (const reference of input.references) {
212
+ newReference ||= !(await store.hasReference(
213
+ p.ownerDid,
214
+ p.id,
215
+ reference.step,
216
+ ));
217
+ await store.recordReference(p.ownerDid, p.id, reference);
218
+ }
219
+ const claimed =
220
+ verificationClaimed || (await store.claimVerification(p.ownerDid, p.id));
221
+ if (!claimed && !newReference)
222
+ return (await store.get(p.ownerDid, p.id)) ?? p;
223
+ ctx.onAttempt?.();
224
+ return withTopicReadBudget(
225
+ ctx.reader,
226
+ ctx.fetcher ?? fetch,
227
+ (reader, fetcher) =>
228
+ reconcileWithinBudget(store, p, input, { ...ctx, reader, fetcher }),
229
+ ctx.signal,
230
+ ).catch(async () => (await store.get(p.ownerDid, p.id)) ?? p);
231
+ }
232
+ async function reconcileWithinBudget(
233
+ store: TopicProposalStore,
234
+ p: TopicActionProposal,
235
+ input: TopicReceiptReferences,
236
+ ctx: TopicVerificationContext,
237
+ ) {
238
+ let current = p;
239
+ const all = [
240
+ ...input.references,
241
+ ...(await store.references(p.ownerDid, p.id)).filter(
242
+ (r) => !input.references.some((s) => s.step === r.step),
243
+ ),
244
+ ];
245
+ for (const reference of all) {
246
+ const effect = await store.recordReference(p.ownerDid, p.id, reference);
247
+ try {
248
+ const receipt = await verifyTopicReference(
249
+ current,
250
+ effect,
251
+ reference,
252
+ ctx,
253
+ );
254
+ current = await store.verifyStep(
255
+ p.ownerDid,
256
+ p.id,
257
+ reference.step,
258
+ receipt,
259
+ );
260
+ } catch (error) {
261
+ if (reference.kind === 'chain' && error instanceof Error) {
262
+ if (error.message === TRANSACTION_CARD_BOUND_MISMATCH)
263
+ // The transaction carries THIS card's memo and other messages: an
264
+ // effect the owner bound to the card went wrong. It stays listed
265
+ // and the card is blocked, never re-permitted over it.
266
+ await store.blockExecution(p.ownerDid, p.id, error.message);
267
+ else if (TRANSACTION_REJECTIONS.has(error.message))
268
+ // Provably another transaction (the chain holds it under another
269
+ // card's memo or none): set aside so the owner can record the
270
+ // right one.
271
+ await store.rejectReference(p.ownerDid, p.id, reference);
272
+ }
273
+ // A read that failed or a transaction not indexed yet is retried on
274
+ // the cooldown.
275
+ current = (await store.get(p.ownerDid, p.id)) ?? current;
276
+ }
277
+ }
278
+ return current;
279
+ }
@@ -0,0 +1,223 @@
1
+ import protobuf from 'protobufjs/minimal.js';
2
+ import { boundedBytes } from '../core/domain-context/transport';
3
+ import {
4
+ TxRaw,
5
+ TxBody,
6
+ } from '@ixo/impactxclient-sdk/module/codegen/cosmos/tx/v1beta1/tx.js';
7
+ import { MsgSend } from '@ixo/impactxclient-sdk/module/codegen/cosmos/bank/v1beta1/tx.js';
8
+ import { MsgTransferToken } from '@ixo/impactxclient-sdk/module/codegen/ixo/token/v1beta1/tx.js';
9
+ import type { ExpectedTopicEffect } from '@ixo/ixo-transaction/topic-proposals';
10
+ import type { ActionProposal } from '@ixo/ixo-transaction/action-proposals';
11
+ import { z } from 'zod';
12
+
13
+ const TxResponse = z.object({
14
+ result: z.object({
15
+ hash: z.string().regex(/^[a-fA-F0-9]{64}$/),
16
+ height: z.string().regex(/^[1-9]\d*$/),
17
+ tx: z.string().min(1).max(1_000_000),
18
+ tx_result: z.object({ code: z.number().int().nonnegative() }),
19
+ }),
20
+ });
21
+ const StatusResponse = z.object({
22
+ result: z.object({ node_info: z.object({ network: z.string().min(1) }) }),
23
+ });
24
+ const decode64 = (value: string) =>
25
+ Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
26
+ const hex = (bytes: Uint8Array) =>
27
+ Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
28
+ const equal = (a: Uint8Array, b: Uint8Array) =>
29
+ a.length === b.length && a.every((v, i) => v === b[i]);
30
+ export type VerifiedActionTransaction = {
31
+ transactionHash: string;
32
+ height: number;
33
+ code: number;
34
+ verifiedAt: string;
35
+ };
36
+
37
+ /**
38
+ * How long a chain RPC's answer to `/status` is trusted per isolate: the
39
+ * network an origin serves does not change between lookups, and `/status`
40
+ * would otherwise be half of all verification traffic.
41
+ */
42
+ export const CHAIN_NETWORK_CACHE_MS = 5 * 60_000;
43
+ const chainNetworks = new Map<string, { network: string; at: number }>();
44
+ /** Tests only: forget every cached `/status` answer. */
45
+ export function resetChainNetworkCache(): void {
46
+ chainNetworks.clear();
47
+ }
48
+ /**
49
+ * The network (chain id) an RPC origin serves, from `/status`, cached per
50
+ * isolate for `CHAIN_NETWORK_CACHE_MS`. Only a parsed answer is cached; a
51
+ * failed read is retried on the next lookup.
52
+ */
53
+ export async function chainNetwork(
54
+ origin: string,
55
+ readStatus: () => Promise<unknown>,
56
+ now = Date.now(),
57
+ ): Promise<string> {
58
+ const cached = chainNetworks.get(origin);
59
+ if (cached && now - cached.at < CHAIN_NETWORK_CACHE_MS) return cached.network;
60
+ const network = StatusResponse.parse(await readStatus()).result.node_info
61
+ .network;
62
+ chainNetworks.set(origin, { network, at: now });
63
+ return network;
64
+ }
65
+
66
+ /**
67
+ * The node does not hold the transaction (yet): not indexed, or not
68
+ * broadcast. CometBFT answers `/tx` for an unknown hash with a JSON-RPC
69
+ * error whose text says "not found" (HTTP 200 or 500 by version).
70
+ */
71
+ export const TRANSACTION_NOT_FOUND = 'Transaction not found';
72
+ function isTransactionNotFound(text: string): boolean {
73
+ try {
74
+ const body = JSON.parse(text) as {
75
+ error?: { message?: unknown; data?: unknown };
76
+ };
77
+ return (
78
+ !!body.error &&
79
+ /not found/i.test(`${body.error.message ?? ''} ${body.error.data ?? ''}`)
80
+ );
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ export async function verifyActionTransaction(
87
+ proposal: ActionProposal,
88
+ transactionHash: string,
89
+ rpcOrigin: string,
90
+ fetcher: typeof fetch = fetch,
91
+ /** The caller's wall budget (the alarm's); each read also stops at 10 s. */
92
+ signal?: AbortSignal,
93
+ ): Promise<VerifiedActionTransaction> {
94
+ return verifyTransaction(
95
+ proposal.action.chainId,
96
+ proposal.action.inputs.messages.map((expected) => {
97
+ const value =
98
+ expected.typeUrl === '/cosmos.bank.v1beta1.MsgSend'
99
+ ? MsgSend.encode(
100
+ MsgSend.fromJSON(expected.value),
101
+ new protobuf.Writer(),
102
+ ).finish()
103
+ : expected.typeUrl === '/ixo.token.v1beta1.MsgTransferToken'
104
+ ? MsgTransferToken.encode(
105
+ MsgTransferToken.fromJSON(expected.value),
106
+ new protobuf.Writer(),
107
+ ).finish()
108
+ : undefined;
109
+ if (!value) throw new Error('Unsupported message');
110
+ return { typeUrl: expected.typeUrl, value };
111
+ }),
112
+ proposal.id,
113
+ transactionHash,
114
+ rpcOrigin,
115
+ fetcher,
116
+ signal,
117
+ );
118
+ }
119
+ export async function verifyTopicGrantTransaction(
120
+ effect: Extract<ExpectedTopicEffect, { kind: 'chain' }>,
121
+ transactionHash: string,
122
+ rpcOrigin: string,
123
+ fetcher: typeof fetch = fetch,
124
+ signal?: AbortSignal,
125
+ ): Promise<VerifiedActionTransaction> {
126
+ return verifyTransaction(
127
+ effect.chainId,
128
+ effect.messages.map((m) => ({
129
+ typeUrl: m.typeUrl,
130
+ value: decode64(m.valueBase64),
131
+ })),
132
+ effect.memo,
133
+ transactionHash,
134
+ rpcOrigin,
135
+ fetcher,
136
+ signal,
137
+ );
138
+ }
139
+ async function verifyTransaction(
140
+ chainId: string,
141
+ messages: { typeUrl: string; value: Uint8Array }[],
142
+ memo: string,
143
+ transactionHash: string,
144
+ rpcOrigin: string,
145
+ fetcher: typeof fetch,
146
+ signal?: AbortSignal,
147
+ ): Promise<VerifiedActionTransaction> {
148
+ const origin = new URL(rpcOrigin);
149
+ if (
150
+ origin.protocol !== 'https:' ||
151
+ origin.username ||
152
+ origin.password ||
153
+ origin.search ||
154
+ origin.hash
155
+ )
156
+ throw new Error('Expected configured HTTPS RPC origin');
157
+ const request = async (path: string, notFound = false) => {
158
+ const response = await fetcher(
159
+ new URL(path, `${origin.href.replace(/\/$/, '')}/`),
160
+ {
161
+ redirect: 'error',
162
+ signal: signal
163
+ ? AbortSignal.any([signal, AbortSignal.timeout(10_000)])
164
+ : AbortSignal.timeout(10_000),
165
+ },
166
+ );
167
+ if (notFound && response.status === 404)
168
+ throw new Error(TRANSACTION_NOT_FOUND);
169
+ if (!response.ok && !notFound)
170
+ throw new Error('Chain verification unavailable');
171
+ const text = new TextDecoder().decode(
172
+ await boundedBytes(response, 1_500_000),
173
+ );
174
+ if (notFound && isTransactionNotFound(text))
175
+ throw new Error(TRANSACTION_NOT_FOUND);
176
+ if (!response.ok) throw new Error('Chain verification unavailable');
177
+ return JSON.parse(text) as unknown;
178
+ };
179
+ const network = await chainNetwork(origin.href, () => request('status'));
180
+ if (network !== chainId) throw new Error('Chain mismatch');
181
+ const tx = TxResponse.parse(
182
+ await request(
183
+ `tx?hash=0x${transactionHash.toUpperCase()}&prove=false`,
184
+ true,
185
+ ),
186
+ ).result;
187
+ const bytes = decode64(tx.tx);
188
+ const digest = hex(
189
+ new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
190
+ );
191
+ if (
192
+ digest !== transactionHash.toLowerCase() ||
193
+ tx.hash.toLowerCase() !== digest
194
+ )
195
+ throw new Error('Transaction hash mismatch');
196
+ const raw = TxRaw.decode(bytes);
197
+ if (raw.signatures.length === 0) throw new Error('Unsigned transaction');
198
+ const body = TxBody.decode(raw.bodyBytes);
199
+ // The memo binds the transaction to its card (the card id for a transfer,
200
+ // the frozen topic memo for a grant): a transaction signed for another
201
+ // card, or with no card at all, never verifies this one.
202
+ if (body.memo !== memo) throw new Error('Transaction memo mismatch');
203
+ if (body.messages.length !== messages.length)
204
+ throw new Error('Transaction subject mismatch');
205
+ for (let i = 0; i < messages.length; i++) {
206
+ const actual = body.messages[i]!;
207
+ const expected = messages[i]!;
208
+ if (
209
+ actual.typeUrl !== expected.typeUrl ||
210
+ !equal(actual.value, expected.value)
211
+ )
212
+ throw new Error('Transaction subject mismatch');
213
+ }
214
+ const height = Number(tx.height);
215
+ if (!Number.isSafeInteger(height))
216
+ throw new Error('Invalid transaction height');
217
+ return {
218
+ transactionHash: transactionHash.toUpperCase(),
219
+ height,
220
+ code: tx.tx_result.code,
221
+ verifiedAt: new Date().toISOString(),
222
+ };
223
+ }
@@ -238,7 +238,12 @@ export function channelAuthConfig(
238
238
  const BindingVerdict = z.strictObject({ active: z.boolean() });
239
239
 
240
240
  export async function assertActiveChannelBinding(
241
- identity: TurnIdentity,
241
+ identity: Pick<TurnIdentity, 'userDid'> & {
242
+ channel?: Pick<
243
+ NonNullable<TurnIdentity['channel']>,
244
+ 'provider' | 'bindingId' | 'bindingRevision'
245
+ >;
246
+ },
242
247
  env: Pick<
243
248
  OracleWorkerEnv,
244
249
  'ORACLE_DID' | 'AUTH_HUB_CHANNEL_SERVICE_KEY' | 'AUTH_HUB' | 'AUTH_HUB_URL'
@@ -1,3 +1,5 @@
1
+ import type { ActionOutcomeWire } from '@ixo/ixo-transaction/topic-proposals';
2
+ import type { ActionOutcomeV1 } from '@ixo/action-protocol';
1
3
  import { z } from 'zod';
2
4
  import type { ReplyPlan } from '../delivery/types';
3
5
 
@@ -76,6 +78,8 @@ export interface ChannelTurnResponse {
76
78
  * message for gateways that predate plans.
77
79
  */
78
80
  plan?: ReplyPlan;
81
+ actionOutcomes?: ActionOutcomeWire[];
82
+ hubActionOutcomes?: ActionOutcomeV1[];
79
83
  }
80
84
 
81
85
  export type ChannelErrorStatus =
@@ -640,6 +640,7 @@ export class ChannelTurns {
640
640
  channel: {
641
641
  provider: input.provider,
642
642
  bindingId: input.bindingId,
643
+ bindingRevision: input.bindingRevision,
643
644
  remoteMessageRef: input.remoteMessageRef,
644
645
  },
645
646
  });
package/src/core/env.ts CHANGED
@@ -73,14 +73,66 @@ const secureUrlSchema = z.string().refine((raw) => isSecureUrl(raw), {
73
73
  message: 'must be an https URL (http only on localhost)',
74
74
  });
75
75
 
76
+ /**
77
+ * An https base URL for a chain RPC (a path such as `/rpc/` is fine): no
78
+ * credentials, query or fragment, which the verifier would refuse at the
79
+ * first lookup otherwise.
80
+ */
81
+ const rpcOriginSchema = z.string().refine(
82
+ (raw) => {
83
+ try {
84
+ const url = new URL(raw);
85
+ return (
86
+ url.protocol === 'https:' &&
87
+ !url.username &&
88
+ !url.password &&
89
+ !url.search &&
90
+ !url.hash
91
+ );
92
+ } catch {
93
+ return false;
94
+ }
95
+ },
96
+ { message: 'must be an https URL without credentials, query or fragment' },
97
+ );
98
+ /** The Portal origin action links point at: https, host only. */
99
+ const portalOriginSchema = z.string().refine(
100
+ (raw) => {
101
+ try {
102
+ const url = new URL(raw);
103
+ return (
104
+ url.protocol === 'https:' &&
105
+ url.pathname === '/' &&
106
+ !url.username &&
107
+ !url.password &&
108
+ !url.search &&
109
+ !url.hash
110
+ );
111
+ } catch {
112
+ return false;
113
+ }
114
+ },
115
+ { message: 'must be an https origin with no path, query or fragment' },
116
+ );
117
+
76
118
  export const baseEnvSchema = z.object({
119
+ // --- action proposals (src/action-proposals, docs/action-proposals.md) ---
120
+ ACTION_PROPOSALS_RPC_TESTNET: rpcOriginSchema.optional(),
121
+ ACTION_PROPOSALS_RPC_DEVNET: rpcOriginSchema.optional(),
122
+ ACTION_PROPOSALS_RPC_MAINNET: rpcOriginSchema.optional(),
123
+ ACTION_PROPOSALS_ENABLED: z.enum(['true', 'false']).default('false'),
124
+ ACTION_TOPIC_PROPOSALS_ENABLED: z.enum(['true', 'false']).default('false'),
125
+ ACTION_PROPOSALS_PORTAL_ORIGIN: portalOriginSchema.optional(),
77
126
  // --- identity -----------------------------------------------------------
78
127
  ORACLE_NAME: z.string().min(1),
79
128
  /** UCAN audience — the DID users address invocations/delegations to. */
80
129
  ORACLE_DID: z.string().min(1),
81
130
  /**
82
131
  * On-chain entity DID surfaced to the agent as `identity.entityDid`. Falls
83
- * back to `ORACLE_DID` when unset (see `createRuntimeCore`).
132
+ * back to `ORACLE_DID` when unset (see `createRuntimeCore`) for rooms and
133
+ * feedback. The memory plugin redeclares it as required
134
+ * (`did:ixo:entity:<32 hex>`): the Memory Engine keys every space by it and
135
+ * never receives the fallback.
84
136
  */
85
137
  ORACLE_ENTITY_DID: z.string().min(1).optional(),
86
138
  NETWORK: z.enum(['mainnet', 'testnet', 'devnet']).default('mainnet'),
@@ -454,11 +506,10 @@ export function validateEnv(
454
506
  ): ValidateEnvResult {
455
507
  const result = schema.safeParse(env);
456
508
  if (result.success) {
457
- return {
458
- valid: true,
459
- config: result.data,
460
- errors: [],
461
- };
509
+ const errors = actionProposalEnvErrors(result.data);
510
+ return errors.length
511
+ ? { valid: false, config: {}, errors }
512
+ : { valid: true, config: result.data, errors: [] };
462
513
  }
463
514
 
464
515
  const errors: ValidateEnvError[] = result.error.issues.map((issue) => {
@@ -470,3 +521,39 @@ export function validateEnv(
470
521
 
471
522
  return { valid: false, config: {}, errors };
472
523
  }
524
+
525
+ /**
526
+ * Switching action proposals on without the settings they run on is a
527
+ * configuration error, not a runtime clarification: a card could be
528
+ * prepared with no link to review it at, or signed and never verified.
529
+ */
530
+ function actionProposalEnvErrors(
531
+ config: Record<string, unknown>,
532
+ ): ValidateEnvError[] {
533
+ const errors: ValidateEnvError[] = [];
534
+ const enabled = config['ACTION_PROPOSALS_ENABLED'] === 'true';
535
+ if (enabled && !config['ACTION_PROPOSALS_PORTAL_ORIGIN'])
536
+ errors.push({
537
+ plugin: 'core',
538
+ field: 'ACTION_PROPOSALS_PORTAL_ORIGIN',
539
+ message: 'required when ACTION_PROPOSALS_ENABLED is true',
540
+ });
541
+ if (
542
+ enabled &&
543
+ !config['ACTION_PROPOSALS_RPC_DEVNET'] &&
544
+ !config['ACTION_PROPOSALS_RPC_TESTNET']
545
+ )
546
+ errors.push({
547
+ plugin: 'core',
548
+ field: 'ACTION_PROPOSALS_RPC_TESTNET',
549
+ message:
550
+ 'ACTION_PROPOSALS_RPC_DEVNET or ACTION_PROPOSALS_RPC_TESTNET is required when ACTION_PROPOSALS_ENABLED is true',
551
+ });
552
+ if (config['ACTION_TOPIC_PROPOSALS_ENABLED'] === 'true' && !enabled)
553
+ errors.push({
554
+ plugin: 'core',
555
+ field: 'ACTION_TOPIC_PROPOSALS_ENABLED',
556
+ message: 'requires ACTION_PROPOSALS_ENABLED=true',
557
+ });
558
+ return errors;
559
+ }
@@ -42,6 +42,14 @@ export const mainAgentRequestContextSchema = z.object({
42
42
  id: z.string(),
43
43
  client: z.enum(['portal', 'matrix', 'slack', 'channel']),
44
44
  requestId: z.string(),
45
+ runId: z.string().optional(),
46
+ channel: z
47
+ .object({
48
+ provider: z.string(),
49
+ bindingId: z.string(),
50
+ bindingRevision: z.number().int().positive(),
51
+ })
52
+ .optional(),
45
53
  wsId: z.string().optional(),
46
54
  roomId: z.string().optional(),
47
55
  }),
@@ -300,6 +300,8 @@ export async function createMainAgent(
300
300
  id: requestCtx.session.id,
301
301
  client: requestCtx.session.client,
302
302
  requestId: requestCtx.session.requestId,
303
+ runId: requestCtx.session.runId,
304
+ channel: requestCtx.session.channel,
303
305
  wsId: requestCtx.session.wsId,
304
306
  roomId: requestCtx.session.roomId,
305
307
  ...(surface ? { surface } : {}),