@ixo/oracle-runtime-workers 0.21.2 → 0.23.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/README.md +16 -13
- package/package.json +10 -4
- package/src/action-proposals/policy.ts +54 -0
- package/src/action-proposals/reconcile.ts +87 -0
- package/src/action-proposals/store.ts +863 -0
- package/src/action-proposals/topic-budget.ts +84 -0
- package/src/action-proposals/topic-chain.ts +272 -0
- package/src/action-proposals/topic-effects.ts +44 -0
- package/src/action-proposals/topic-errors.ts +79 -0
- package/src/action-proposals/topic-history.ts +267 -0
- package/src/action-proposals/topic-matrix.ts +292 -0
- package/src/action-proposals/topic-prepare.ts +299 -0
- package/src/action-proposals/topic-store.ts +999 -0
- package/src/action-proposals/topic-verify.ts +279 -0
- package/src/action-proposals/verify.ts +223 -0
- package/src/channels/auth.ts +6 -1
- package/src/channels/contract.ts +4 -0
- package/src/channels/turns.ts +1 -0
- package/src/core/env.ts +107 -5
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +16 -0
- package/src/core/main-agent.ts +26 -0
- package/src/core/middlewares/tool-execution.ts +87 -46
- package/src/core/middlewares/tool-marks.ts +54 -19
- package/src/core/runtime-context.ts +27 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/active-runs.ts +171 -0
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +86 -3
- package/src/do/run-coordinator.ts +30 -0
- package/src/do/run-request.ts +15 -2
- package/src/do/run-store-test-do.ts +10 -0
- package/src/do/run-store.ts +66 -13
- package/src/do/turn-body.ts +13 -1
- package/src/do/user-oracle-do.ts +1420 -17
- package/src/hub-actions/client.ts +163 -0
- package/src/hub-actions/request.ts +110 -0
- package/src/hub-actions/store.ts +485 -0
- package/src/matrix/gateway-do.ts +176 -0
- package/src/matrix/reply-parts.ts +8 -2
- package/src/plugin-api/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +117 -7
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
- package/src/plugins/portal/declared-tools.ts +12 -0
- package/src/plugins/portal/portal.plugin.ts +22 -8
- package/src/realtime/frontend-call-registry.ts +147 -27
- package/src/realtime/realtime-endpoint.ts +335 -41
- package/src/realtime/session-socket-hub.ts +90 -19
- package/src/shell/app.ts +218 -2
- package/src/sqlite/sessions-store.ts +22 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertContract,
|
|
3
|
+
resolveEffectiveTopicShape,
|
|
4
|
+
parseTopicOperationPayload,
|
|
5
|
+
} from '@ixo/topic-protocol';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { canonicalizeIxoJson } from '@ixo/ixo-transaction/action-proposals';
|
|
8
|
+
import type { TopicMatrixReader } from './topic-matrix';
|
|
9
|
+
import {
|
|
10
|
+
TopicMatrixEventSchema,
|
|
11
|
+
readTopicEvent,
|
|
12
|
+
type TopicMatrixEvent,
|
|
13
|
+
} from './topic-matrix';
|
|
14
|
+
import { digestTopicProposalEffect } from './topic-store';
|
|
15
|
+
import { ActionProposalConflict } from './store';
|
|
16
|
+
|
|
17
|
+
const State = z.object({
|
|
18
|
+
version: z.literal(4),
|
|
19
|
+
topicId: z.string(),
|
|
20
|
+
anchor: z.object({ roomId: z.string(), rootEventId: z.string() }),
|
|
21
|
+
contracts: z.object({
|
|
22
|
+
proposed: z.unknown().optional(),
|
|
23
|
+
effective: z.unknown().optional(),
|
|
24
|
+
}),
|
|
25
|
+
projection: z.object({ revision: z.string(), contractRevision: z.string() }),
|
|
26
|
+
shape: z.object({ digest: z.string() }),
|
|
27
|
+
});
|
|
28
|
+
const Ref = z.object({
|
|
29
|
+
revision: z.string(),
|
|
30
|
+
bodyHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
31
|
+
bodyRef: z.object({
|
|
32
|
+
provider: z.literal('matrix'),
|
|
33
|
+
roomId: z.string(),
|
|
34
|
+
eventId: z.string(),
|
|
35
|
+
contentHash: z.string(),
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
const Body = z
|
|
39
|
+
.object({
|
|
40
|
+
version: z.literal(4),
|
|
41
|
+
revision: z.string(),
|
|
42
|
+
authoredBy: z.string(),
|
|
43
|
+
kindRef: z.object({
|
|
44
|
+
source: z.literal('standard'),
|
|
45
|
+
kind: z.enum([
|
|
46
|
+
'project',
|
|
47
|
+
'task',
|
|
48
|
+
'agent_task',
|
|
49
|
+
'proposal',
|
|
50
|
+
'evaluation',
|
|
51
|
+
'claims',
|
|
52
|
+
'question',
|
|
53
|
+
'discussion',
|
|
54
|
+
'incident',
|
|
55
|
+
]),
|
|
56
|
+
}),
|
|
57
|
+
baseRecipe: z.enum([
|
|
58
|
+
'project',
|
|
59
|
+
'flow',
|
|
60
|
+
'proposal',
|
|
61
|
+
'evaluation',
|
|
62
|
+
'claims',
|
|
63
|
+
'research',
|
|
64
|
+
'discussion',
|
|
65
|
+
'incident',
|
|
66
|
+
]),
|
|
67
|
+
shapeSources: z.array(z.unknown()),
|
|
68
|
+
shapeDigest: z.string(),
|
|
69
|
+
claimBinding: z
|
|
70
|
+
.object({ entityDid: z.string(), collectionId: z.string() })
|
|
71
|
+
.passthrough()
|
|
72
|
+
.optional(),
|
|
73
|
+
roles: z
|
|
74
|
+
.array(z.object({ roleId: z.string(), assignees: z.array(z.string()) }))
|
|
75
|
+
.default([]),
|
|
76
|
+
ownerId: z.string().optional(),
|
|
77
|
+
})
|
|
78
|
+
.passthrough();
|
|
79
|
+
const ContractOperation = z.object({
|
|
80
|
+
version: z.literal(2),
|
|
81
|
+
operationId: z.string(),
|
|
82
|
+
topicId: z.string(),
|
|
83
|
+
type: z.enum(['propose-contract', 'update-contract', 'supersede-contract']),
|
|
84
|
+
previousRevision: z.string(),
|
|
85
|
+
actor: z.string(),
|
|
86
|
+
createdAt: z.string(),
|
|
87
|
+
payload: z.record(z.string(), z.unknown()),
|
|
88
|
+
capability: z.string().optional(),
|
|
89
|
+
});
|
|
90
|
+
export async function readTopicAuthority(
|
|
91
|
+
reader: TopicMatrixReader,
|
|
92
|
+
room: string,
|
|
93
|
+
topicId: string,
|
|
94
|
+
owner: string,
|
|
95
|
+
/** The room state the caller already read and parsed, if any. */
|
|
96
|
+
roomState?: TopicMatrixEvent[],
|
|
97
|
+
) {
|
|
98
|
+
const stateEvents =
|
|
99
|
+
roomState ??
|
|
100
|
+
z
|
|
101
|
+
.array(TopicMatrixEventSchema)
|
|
102
|
+
.max(2048)
|
|
103
|
+
.parse(await reader.state(room));
|
|
104
|
+
const stateEvent = stateEvents.find(
|
|
105
|
+
(e) => e.type === 'ixo.topic.contract' && e.state_key === topicId,
|
|
106
|
+
);
|
|
107
|
+
if (!stateEvent)
|
|
108
|
+
throw new ActionProposalConflict('Canonical topic state is unavailable');
|
|
109
|
+
const state = State.parse(stateEvent.content);
|
|
110
|
+
if (state.topicId !== topicId || state.anchor.roomId !== room)
|
|
111
|
+
throw new ActionProposalConflict('Topic locator mismatch');
|
|
112
|
+
const root = await readTopicEvent(reader, room, state.anchor.rootEventId);
|
|
113
|
+
if (
|
|
114
|
+
root.sender !== owner ||
|
|
115
|
+
root.type !== 'm.room.message' ||
|
|
116
|
+
root.room_id !== room
|
|
117
|
+
)
|
|
118
|
+
throw new ActionProposalConflict(
|
|
119
|
+
'Only owner-authored topic history is supported',
|
|
120
|
+
);
|
|
121
|
+
const history = z
|
|
122
|
+
.array(TopicMatrixEventSchema)
|
|
123
|
+
.max(256)
|
|
124
|
+
// Only the owner's relations are fetched and decrypted: authority here
|
|
125
|
+
// is owner-authored, and another member's relations must neither cost a
|
|
126
|
+
// decrypt nor fork or overflow the history read.
|
|
127
|
+
.parse(await reader.history(room, root.event_id, { sender: owner }));
|
|
128
|
+
const contracts = history
|
|
129
|
+
.filter((e) => e.sender === owner && e.type === 'ixo.topic.operation')
|
|
130
|
+
.flatMap((event) => {
|
|
131
|
+
const raw = z
|
|
132
|
+
.object({
|
|
133
|
+
operation: z
|
|
134
|
+
.object({ topicId: z.string(), type: z.string() })
|
|
135
|
+
.passthrough(),
|
|
136
|
+
})
|
|
137
|
+
.parse(event.content).operation;
|
|
138
|
+
if (
|
|
139
|
+
raw.topicId !== topicId ||
|
|
140
|
+
!['propose-contract', 'update-contract', 'supersede-contract'].includes(
|
|
141
|
+
raw.type,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return [];
|
|
145
|
+
const operation = ContractOperation.parse(raw);
|
|
146
|
+
if (
|
|
147
|
+
event.room_id !== room ||
|
|
148
|
+
z
|
|
149
|
+
.object({
|
|
150
|
+
'm.relates_to': z.object({
|
|
151
|
+
rel_type: z.literal('m.reference'),
|
|
152
|
+
event_id: z.literal(root.event_id),
|
|
153
|
+
}),
|
|
154
|
+
})
|
|
155
|
+
.safeParse(event.content).success !== true
|
|
156
|
+
)
|
|
157
|
+
throw new ActionProposalConflict(
|
|
158
|
+
'Topic operation root binding mismatch',
|
|
159
|
+
);
|
|
160
|
+
parseTopicOperationPayload(operation);
|
|
161
|
+
return [{ event, operation }];
|
|
162
|
+
});
|
|
163
|
+
let prior = root.event_id;
|
|
164
|
+
let selected: (typeof contracts)[number] | undefined;
|
|
165
|
+
const remaining = [...contracts];
|
|
166
|
+
while (remaining.length) {
|
|
167
|
+
const next = remaining.filter(
|
|
168
|
+
(e) => e.operation?.previousRevision === prior,
|
|
169
|
+
);
|
|
170
|
+
if (next.length !== 1)
|
|
171
|
+
throw new ActionProposalConflict('Topic history is incomplete or forked');
|
|
172
|
+
const entry = next[0]!;
|
|
173
|
+
const op = entry.operation;
|
|
174
|
+
if (entry.event.sender !== owner || op.actor !== owner)
|
|
175
|
+
throw new ActionProposalConflict(
|
|
176
|
+
'Delegated contract mutation requires an unsupported authority proof',
|
|
177
|
+
);
|
|
178
|
+
if (
|
|
179
|
+
op.payload.previousRevision !== prior &&
|
|
180
|
+
op.payload.topicPreviousRevision !== prior
|
|
181
|
+
)
|
|
182
|
+
throw new ActionProposalConflict('Topic operation revision mismatch');
|
|
183
|
+
prior = op.operationId;
|
|
184
|
+
selected = entry;
|
|
185
|
+
remaining.splice(remaining.indexOf(entry), 1);
|
|
186
|
+
}
|
|
187
|
+
if (!selected?.operation || prior !== state.projection.contractRevision)
|
|
188
|
+
throw new ActionProposalConflict(
|
|
189
|
+
'Topic projection is not backed by complete contract history',
|
|
190
|
+
);
|
|
191
|
+
const ref = Ref.parse(
|
|
192
|
+
selected.operation.payload.contractRef ??
|
|
193
|
+
selected.operation.payload.contract,
|
|
194
|
+
);
|
|
195
|
+
if (
|
|
196
|
+
ref.revision !== prior ||
|
|
197
|
+
ref.bodyRef.roomId !== room ||
|
|
198
|
+
ref.bodyRef.contentHash !== ref.bodyHash
|
|
199
|
+
)
|
|
200
|
+
throw new ActionProposalConflict('Pinned body reference mismatch');
|
|
201
|
+
const selectedHead = Ref.parse(
|
|
202
|
+
state.contracts.proposed ?? state.contracts.effective,
|
|
203
|
+
);
|
|
204
|
+
if (canonicalizeIxoJson(selectedHead) !== canonicalizeIxoJson(ref))
|
|
205
|
+
throw new ActionProposalConflict(
|
|
206
|
+
'Projection body head differs from authenticated history',
|
|
207
|
+
);
|
|
208
|
+
const bodyEvent = await readTopicEvent(reader, room, ref.bodyRef.eventId);
|
|
209
|
+
if (
|
|
210
|
+
bodyEvent.sender !== owner ||
|
|
211
|
+
bodyEvent.room_id !== room ||
|
|
212
|
+
bodyEvent.type !== 'ixo.topic.contract.body' ||
|
|
213
|
+
z
|
|
214
|
+
.object({ 'm.relates_to': z.object({ event_id: z.string() }) })
|
|
215
|
+
.parse(bodyEvent.content)['m.relates_to'].event_id !== root.event_id
|
|
216
|
+
)
|
|
217
|
+
throw new ActionProposalConflict('Pinned body sender mismatch');
|
|
218
|
+
const body = Body.parse(bodyEvent.content.contract);
|
|
219
|
+
assertContract('topic-contract-body', body);
|
|
220
|
+
const bodyHash = `sha256:${await digestTopicProposalEffect(bodyEvent.content.contract)}`;
|
|
221
|
+
if (
|
|
222
|
+
bodyHash !== ref.bodyHash ||
|
|
223
|
+
body.revision !== prior ||
|
|
224
|
+
body.authoredBy !== owner
|
|
225
|
+
)
|
|
226
|
+
throw new ActionProposalConflict('Pinned body integrity mismatch');
|
|
227
|
+
const shape = resolveEffectiveTopicShape({
|
|
228
|
+
baseRecipe: body.baseRecipe,
|
|
229
|
+
kind: body.kindRef.kind,
|
|
230
|
+
// The shape release (`TOPIC_SHAPE_RELEASE`), not the package version.
|
|
231
|
+
sourceVersion: '1.0.0-rc.7',
|
|
232
|
+
});
|
|
233
|
+
if (
|
|
234
|
+
shape.digest !== body.shapeDigest ||
|
|
235
|
+
shape.digest !== state.shape.digest ||
|
|
236
|
+
canonicalizeIxoJson(shape.sources) !==
|
|
237
|
+
canonicalizeIxoJson(body.shapeSources)
|
|
238
|
+
)
|
|
239
|
+
throw new ActionProposalConflict('Pinned shape is unsupported or changed');
|
|
240
|
+
const authority = {
|
|
241
|
+
rootEventId: root.event_id,
|
|
242
|
+
contractBodyEventId: bodyEvent.event_id,
|
|
243
|
+
operationEventId: selected.event.event_id,
|
|
244
|
+
stateEventId: stateEvent.event_id,
|
|
245
|
+
contractBodyHash: bodyHash,
|
|
246
|
+
revision: prior,
|
|
247
|
+
shapeDigest: shape.digest,
|
|
248
|
+
};
|
|
249
|
+
// The state event is re-emitted by ordinary projection activity; the
|
|
250
|
+
// contract head it points at is what carries authority.
|
|
251
|
+
const { stateEventId: _state, ...pinned } = authority;
|
|
252
|
+
return {
|
|
253
|
+
authority,
|
|
254
|
+
body,
|
|
255
|
+
// The fingerprint is the authority chain's head and the pinned body: a
|
|
256
|
+
// new contract operation changes `authority.revision`, while ordinary
|
|
257
|
+
// activity in the topic thread (a reply, a reaction) does not block the
|
|
258
|
+
// owner's claim.
|
|
259
|
+
dependencies: [
|
|
260
|
+
{
|
|
261
|
+
kind: 'topic' as const,
|
|
262
|
+
key: topicId,
|
|
263
|
+
digest: await digestTopicProposalEffect({ authority: pinned, body }),
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { boundedBytes } from '../core/domain-context/transport';
|
|
3
|
+
import { digestTopicProposalEffect } from './topic-store';
|
|
4
|
+
import { ActionProposalConflict } from './store';
|
|
5
|
+
import { TopicReadUnavailable } from './topic-errors';
|
|
6
|
+
import type { TopicActionProposal } from '@ixo/ixo-transaction/topic-proposals';
|
|
7
|
+
|
|
8
|
+
export const TopicMatrixEventSchema = z.object({
|
|
9
|
+
event_id: z.string().startsWith('$').max(512),
|
|
10
|
+
type: z.string().min(1),
|
|
11
|
+
state_key: z.string().optional(),
|
|
12
|
+
content: z.record(z.string(), z.unknown()),
|
|
13
|
+
sender: z.string().regex(/^@[^:]+:.+$/),
|
|
14
|
+
room_id: z.string().optional(),
|
|
15
|
+
origin_server_ts: z.number().optional(),
|
|
16
|
+
});
|
|
17
|
+
export type TopicMatrixEvent = z.infer<typeof TopicMatrixEventSchema>;
|
|
18
|
+
/**
|
|
19
|
+
* What a topic read may tell the gateway about itself: the moment its
|
|
20
|
+
* budget ends (the gateway stops before it, decrypt waits included) and,
|
|
21
|
+
* for history, the only sender whose relations matter (others are never
|
|
22
|
+
* decrypted or counted).
|
|
23
|
+
*/
|
|
24
|
+
export type TopicReadOptions = { deadlineAt?: number; sender?: string };
|
|
25
|
+
export interface TopicMatrixReader {
|
|
26
|
+
state(roomId: string): Promise<unknown>;
|
|
27
|
+
event(
|
|
28
|
+
roomId: string,
|
|
29
|
+
eventId: string,
|
|
30
|
+
opts?: TopicReadOptions,
|
|
31
|
+
): Promise<unknown>;
|
|
32
|
+
history(
|
|
33
|
+
roomId: string,
|
|
34
|
+
root: string,
|
|
35
|
+
opts?: TopicReadOptions,
|
|
36
|
+
): Promise<unknown>;
|
|
37
|
+
}
|
|
38
|
+
const DidResponse = z.object({
|
|
39
|
+
data: z.object({
|
|
40
|
+
iids: z.object({
|
|
41
|
+
nodes: z
|
|
42
|
+
.array(
|
|
43
|
+
z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
service: z.array(
|
|
46
|
+
z.object({ type: z.string(), serviceEndpoint: z.string() }),
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
.max(2),
|
|
51
|
+
}),
|
|
52
|
+
}),
|
|
53
|
+
errors: z.array(z.unknown()).optional(),
|
|
54
|
+
});
|
|
55
|
+
export async function resolveTopicIdentity(
|
|
56
|
+
did: string,
|
|
57
|
+
blocksync: string | undefined,
|
|
58
|
+
fetcher: typeof fetch = fetch,
|
|
59
|
+
) {
|
|
60
|
+
if (!blocksync)
|
|
61
|
+
throw new TopicReadUnavailable('Trusted DID resolver is unavailable');
|
|
62
|
+
const endpoint = new URL(blocksync);
|
|
63
|
+
if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password)
|
|
64
|
+
throw new TopicReadUnavailable('Invalid trusted DID endpoint');
|
|
65
|
+
const response = await fetcher(endpoint, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
redirect: 'error',
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
query:
|
|
71
|
+
'query TopicIdentity($id:String!){iids(filter:{id:{equalTo:$id}}){nodes{id service}}}',
|
|
72
|
+
variables: { id: did },
|
|
73
|
+
}),
|
|
74
|
+
signal: AbortSignal.timeout(5000),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) throw new TopicReadUnavailable('DID lookup unavailable');
|
|
77
|
+
const envelope = DidResponse.safeParse(
|
|
78
|
+
JSON.parse(new TextDecoder().decode(await boundedBytes(response, 65536))),
|
|
79
|
+
);
|
|
80
|
+
// A GraphQL error envelope (`data: null`, `errors`) is the resolver
|
|
81
|
+
// failing, not a fact about the DID.
|
|
82
|
+
if (!envelope.success || envelope.data.errors?.length)
|
|
83
|
+
throw new TopicReadUnavailable('DID lookup unavailable');
|
|
84
|
+
const raw = envelope.data;
|
|
85
|
+
if (raw.data.iids.nodes.length !== 1)
|
|
86
|
+
throw new ActionProposalConflict('Identity is not indexed');
|
|
87
|
+
const record = raw.data.iids.nodes[0]!;
|
|
88
|
+
if (record.id !== did)
|
|
89
|
+
throw new ActionProposalConflict('DID lookup mismatch');
|
|
90
|
+
const services = record.service.filter((s) => s.type === 'MatrixHomeServer');
|
|
91
|
+
if (services.length !== 1)
|
|
92
|
+
throw new ActionProposalConflict(
|
|
93
|
+
'Exactly one Matrix homeserver is required',
|
|
94
|
+
);
|
|
95
|
+
const url = new URL(services[0]!.serviceEndpoint);
|
|
96
|
+
if (
|
|
97
|
+
url.protocol !== 'https:' ||
|
|
98
|
+
url.username ||
|
|
99
|
+
url.password ||
|
|
100
|
+
url.search ||
|
|
101
|
+
url.hash ||
|
|
102
|
+
url.pathname !== '/' ||
|
|
103
|
+
url.port
|
|
104
|
+
)
|
|
105
|
+
throw new ActionProposalConflict('Invalid DID Matrix service');
|
|
106
|
+
const homeserver = url.origin;
|
|
107
|
+
const matrixUserId = `@${did.replaceAll(':', '-')}:${url.hostname}`;
|
|
108
|
+
return {
|
|
109
|
+
did,
|
|
110
|
+
matrixUserId,
|
|
111
|
+
homeserver,
|
|
112
|
+
bindingDigest: await digestTopicProposalEffect({
|
|
113
|
+
did,
|
|
114
|
+
service: services[0],
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* One event, parsed. An event the gateway cannot hand over (not decrypted
|
|
120
|
+
* yet, not found) comes back empty: a read that failed, not a fact about the
|
|
121
|
+
* card.
|
|
122
|
+
*/
|
|
123
|
+
export async function readTopicEvent(
|
|
124
|
+
reader: TopicMatrixReader,
|
|
125
|
+
room: string,
|
|
126
|
+
eventId: string,
|
|
127
|
+
) {
|
|
128
|
+
const raw = await reader.event(room, eventId);
|
|
129
|
+
if (raw === null || raw === undefined)
|
|
130
|
+
throw new TopicReadUnavailable('Topic event is not readable');
|
|
131
|
+
return TopicMatrixEventSchema.parse(raw);
|
|
132
|
+
}
|
|
133
|
+
export async function readTopicRoom(
|
|
134
|
+
reader: TopicMatrixReader,
|
|
135
|
+
room: string,
|
|
136
|
+
owner: string,
|
|
137
|
+
) {
|
|
138
|
+
const state = z
|
|
139
|
+
.array(TopicMatrixEventSchema)
|
|
140
|
+
.max(2048)
|
|
141
|
+
.parse(await reader.state(room));
|
|
142
|
+
if (
|
|
143
|
+
new Set(state.map((e) => `${e.type}\0${e.state_key ?? ''}`)).size !==
|
|
144
|
+
state.length
|
|
145
|
+
)
|
|
146
|
+
throw new TopicReadUnavailable('Room state has duplicate keys');
|
|
147
|
+
const membership = state.find(
|
|
148
|
+
(e) => e.type === 'm.room.member' && e.state_key === owner,
|
|
149
|
+
);
|
|
150
|
+
const power = state.find(
|
|
151
|
+
(e) => e.type === 'm.room.power_levels' && e.state_key === '',
|
|
152
|
+
);
|
|
153
|
+
const encryption = state.find(
|
|
154
|
+
(e) => e.type === 'm.room.encryption' && e.state_key === '',
|
|
155
|
+
);
|
|
156
|
+
const creation = state.find(
|
|
157
|
+
(e) => e.type === 'm.room.create' && e.state_key === '',
|
|
158
|
+
);
|
|
159
|
+
if (membership?.content.membership !== 'join' || !power || !creation)
|
|
160
|
+
throw new ActionProposalConflict(
|
|
161
|
+
'The owner is not a member of the room, or its policy is unreadable',
|
|
162
|
+
);
|
|
163
|
+
const levels = z
|
|
164
|
+
.object({
|
|
165
|
+
users: z.record(z.string(), z.number()).default({}),
|
|
166
|
+
users_default: z.number().default(0),
|
|
167
|
+
events: z.record(z.string(), z.number()).default({}),
|
|
168
|
+
events_default: z.number().default(0),
|
|
169
|
+
state_default: z.number().default(50),
|
|
170
|
+
})
|
|
171
|
+
.parse(power.content);
|
|
172
|
+
const level = levels.users[owner] ?? levels.users_default;
|
|
173
|
+
for (const type of [
|
|
174
|
+
'm.room.message',
|
|
175
|
+
'ixo.topic.contract.body',
|
|
176
|
+
'ixo.topic.operation',
|
|
177
|
+
'ixo.topic.contract',
|
|
178
|
+
]) {
|
|
179
|
+
const required =
|
|
180
|
+
levels.events[type] ??
|
|
181
|
+
(type === 'ixo.topic.contract'
|
|
182
|
+
? levels.state_default
|
|
183
|
+
: levels.events_default);
|
|
184
|
+
if (level < required)
|
|
185
|
+
throw new ActionProposalConflict(
|
|
186
|
+
'Owner cannot write the topic event types',
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const selected = [
|
|
190
|
+
creation,
|
|
191
|
+
membership,
|
|
192
|
+
power,
|
|
193
|
+
...(encryption ? [encryption] : []),
|
|
194
|
+
];
|
|
195
|
+
// Only the facts that carry authority are fingerprinted: the owner's
|
|
196
|
+
// membership value (not their avatar or display name) and each policy
|
|
197
|
+
// event's content (not the event id a re-send would change).
|
|
198
|
+
const topicTypes = [
|
|
199
|
+
'm.room.message',
|
|
200
|
+
'ixo.topic.contract.body',
|
|
201
|
+
'ixo.topic.operation',
|
|
202
|
+
'ixo.topic.contract',
|
|
203
|
+
];
|
|
204
|
+
const authority = (e: TopicMatrixEvent) => ({
|
|
205
|
+
type: e.type,
|
|
206
|
+
state_key: e.state_key ?? '',
|
|
207
|
+
content:
|
|
208
|
+
e.type === 'm.room.member'
|
|
209
|
+
? { membership: e.content.membership }
|
|
210
|
+
: e.type === 'm.room.power_levels'
|
|
211
|
+
? // Only the owner's level and the thresholds for the topic event
|
|
212
|
+
// types: another member's level changing is not this card's
|
|
213
|
+
// business.
|
|
214
|
+
{
|
|
215
|
+
owner: levels.users[owner] ?? levels.users_default,
|
|
216
|
+
events: Object.fromEntries(
|
|
217
|
+
topicTypes.map((type) => [
|
|
218
|
+
type,
|
|
219
|
+
levels.events[type] ??
|
|
220
|
+
(type === 'ixo.topic.contract'
|
|
221
|
+
? levels.state_default
|
|
222
|
+
: levels.events_default),
|
|
223
|
+
]),
|
|
224
|
+
),
|
|
225
|
+
}
|
|
226
|
+
: e.content,
|
|
227
|
+
});
|
|
228
|
+
const dependencies = await Promise.all(
|
|
229
|
+
selected.map(async (e) => ({
|
|
230
|
+
kind: 'matrix' as const,
|
|
231
|
+
key: `${e.type}\0${e.state_key ?? ''}`,
|
|
232
|
+
digest: await digestTopicProposalEffect(authority(e)),
|
|
233
|
+
})),
|
|
234
|
+
);
|
|
235
|
+
if (!encryption)
|
|
236
|
+
dependencies.push({
|
|
237
|
+
kind: 'matrix',
|
|
238
|
+
key: 'm.room.encryption\0',
|
|
239
|
+
digest: await digestTopicProposalEffect(null),
|
|
240
|
+
});
|
|
241
|
+
return { state, dependencies, encrypted: !!encryption };
|
|
242
|
+
}
|
|
243
|
+
export async function assertTopicRoomUnchanged(
|
|
244
|
+
proposal: TopicActionProposal,
|
|
245
|
+
reader: TopicMatrixReader,
|
|
246
|
+
blocksync: string | undefined,
|
|
247
|
+
fetcher: typeof fetch = fetch,
|
|
248
|
+
) {
|
|
249
|
+
const owner = await resolveTopicIdentity(
|
|
250
|
+
proposal.ownerDid,
|
|
251
|
+
blocksync,
|
|
252
|
+
fetcher,
|
|
253
|
+
);
|
|
254
|
+
if (
|
|
255
|
+
owner.bindingDigest !== proposal.action.owner.bindingDigest ||
|
|
256
|
+
owner.matrixUserId !== proposal.action.owner.matrixUserId ||
|
|
257
|
+
owner.homeserver !== proposal.action.owner.homeserver
|
|
258
|
+
)
|
|
259
|
+
throw new ActionProposalConflict('Owner Matrix identity changed');
|
|
260
|
+
const current = await readTopicRoom(
|
|
261
|
+
reader,
|
|
262
|
+
proposal.action.roomId,
|
|
263
|
+
owner.matrixUserId,
|
|
264
|
+
);
|
|
265
|
+
for (const dep of proposal.action.dependencies.filter(
|
|
266
|
+
(d) => d.kind === 'matrix',
|
|
267
|
+
))
|
|
268
|
+
if (
|
|
269
|
+
!current.dependencies.some(
|
|
270
|
+
(d) => d.key === dep.key && d.digest === dep.digest,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
throw new ActionProposalConflict('Room authority changed');
|
|
274
|
+
const people =
|
|
275
|
+
proposal.action.name === 'create_topic'
|
|
276
|
+
? proposal.action.participants
|
|
277
|
+
: proposal.action.grants.map((g) => g.grantee);
|
|
278
|
+
const bindings = await Promise.all(
|
|
279
|
+
people.map((person) =>
|
|
280
|
+
resolveTopicIdentity(person.did, blocksync, fetcher),
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
people.forEach((person, i) => {
|
|
284
|
+
const binding = bindings[i]!;
|
|
285
|
+
if (
|
|
286
|
+
binding.bindingDigest !== person.bindingDigest ||
|
|
287
|
+
binding.matrixUserId !== person.matrixUserId
|
|
288
|
+
)
|
|
289
|
+
throw new ActionProposalConflict('Participant identity changed');
|
|
290
|
+
});
|
|
291
|
+
return current;
|
|
292
|
+
}
|