@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.
- package/README.md +15 -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 +93 -6
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +2 -0
- package/src/core/runtime-context.ts +12 -0
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +39 -0
- package/src/do/user-oracle-do.ts +1275 -8
- 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/memory/session-history-indexer.ts +30 -3
- package/src/memory/user-context-fetcher.ts +36 -12
- package/src/plugin-api/types.ts +54 -0
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/memory/index.ts +4 -0
- package/src/plugins/memory/memory-entity.ts +49 -0
- package/src/plugins/memory/memory-tools.ts +7 -4
- package/src/plugins/memory/memory-ucan.ts +10 -1
- package/src/plugins/memory/memory.plugin.ts +22 -7
- package/src/shell/app.ts +195 -1
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
import { canonicalizeIxoJson } from '@ixo/ixo-transaction/action-proposals';
|
|
2
|
+
import {
|
|
3
|
+
digestTopicEffect,
|
|
4
|
+
ExpectedTopicEffectSchema,
|
|
5
|
+
TopicLifecycleSchema,
|
|
6
|
+
TopicProposalSubjectSchema,
|
|
7
|
+
TopicProposalV2Schema,
|
|
8
|
+
TopicReceiptReferencesSchema,
|
|
9
|
+
assertTopicProposalUnchanged,
|
|
10
|
+
digestTopicProposal,
|
|
11
|
+
type TopicProposalSubject,
|
|
12
|
+
type TopicActionProposal,
|
|
13
|
+
type BeginTopicStep,
|
|
14
|
+
type BeginTopicStepResult,
|
|
15
|
+
type ExpectedTopicEffect,
|
|
16
|
+
type TopicStepId,
|
|
17
|
+
type TopicReceiptReferences,
|
|
18
|
+
type TopicActionReceipt,
|
|
19
|
+
type VerifiedTopicEffectSchema,
|
|
20
|
+
} from '@ixo/ixo-transaction/topic-proposals';
|
|
21
|
+
import type { z } from 'zod';
|
|
22
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
23
|
+
import {
|
|
24
|
+
ActionProposalConflict,
|
|
25
|
+
ACTION_PROPOSAL_RETENTION_MS,
|
|
26
|
+
ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS,
|
|
27
|
+
ACTION_PROPOSALS_OPEN_PER_OWNER,
|
|
28
|
+
ACTION_PROPOSALS_PER_RUN,
|
|
29
|
+
OPEN_PREPARED_MS,
|
|
30
|
+
RECEIPT_MAX_ATTEMPTS,
|
|
31
|
+
RECEIPT_RETRY_BASE_MS,
|
|
32
|
+
RECEIPT_RETRY_MAX_MS,
|
|
33
|
+
VERIFY_MAX_ATTEMPTS,
|
|
34
|
+
VERIFY_RETRY_BASE_MS,
|
|
35
|
+
VERIFY_RETRY_MAX_MS,
|
|
36
|
+
type ActionReceiptOrigin,
|
|
37
|
+
} from './store';
|
|
38
|
+
|
|
39
|
+
type VerifiedEffect = z.infer<typeof VerifiedTopicEffectSchema>;
|
|
40
|
+
/**
|
|
41
|
+
* How long after the claim a topic's remaining steps may still start. The
|
|
42
|
+
* card's own expiry bounds the FIRST step only: a topic whose anchor and
|
|
43
|
+
* body are posted at minute fourteen must be allowed its operation and
|
|
44
|
+
* snapshot, or the room is left with a half-created topic.
|
|
45
|
+
*/
|
|
46
|
+
export const TOPIC_EXECUTION_WINDOW_MS = 60 * 60_000;
|
|
47
|
+
const backoff = (base: number, max: number, attempts: number) =>
|
|
48
|
+
Math.min(base * 2 ** attempts, max);
|
|
49
|
+
/** Validated cards remembered per store, keyed on their stored text. */
|
|
50
|
+
const VALIDATED_CACHE_ENTRIES = 16;
|
|
51
|
+
export class TopicProposalStore {
|
|
52
|
+
/**
|
|
53
|
+
* `assertTopicProposalUnchanged` per stored text (subject, digest and
|
|
54
|
+
* lifecycle): one operation reads a card many times, and for a topic
|
|
55
|
+
* creation every check rebuilds the whole plan. A changed row is a new
|
|
56
|
+
* key, so it is validated again; entries are handed out as copies.
|
|
57
|
+
*/
|
|
58
|
+
private validated = new Map<string, TopicActionProposal>();
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly db: DoSqliteDatabase,
|
|
61
|
+
private readonly now = Date.now,
|
|
62
|
+
) {}
|
|
63
|
+
async setup(): Promise<void> {
|
|
64
|
+
// The unreleased first shape of these tables (no status, session, run or
|
|
65
|
+
// attempt columns) exists only where a pre-release build of the branch
|
|
66
|
+
// ran; it is rebuilt rather than migrated, and the fact logged, so an
|
|
67
|
+
// object holding it boots instead of failing every request on the
|
|
68
|
+
// index below.
|
|
69
|
+
const columns = await this.db.exec<{ name: string }>(
|
|
70
|
+
`PRAGMA table_info(topic_action_proposals)`,
|
|
71
|
+
);
|
|
72
|
+
if (columns.length && !columns.some((c) => c.name === 'session_id')) {
|
|
73
|
+
console.warn(
|
|
74
|
+
'[topic-actions] rebuilding pre-release topic_action_proposals tables',
|
|
75
|
+
);
|
|
76
|
+
await this.db.run(`DROP TABLE IF EXISTS topic_action_steps`);
|
|
77
|
+
await this.db.run(`DROP TABLE IF EXISTS topic_action_proposals`);
|
|
78
|
+
}
|
|
79
|
+
await this.db.run(
|
|
80
|
+
`CREATE TABLE IF NOT EXISTS topic_action_proposals(id TEXT PRIMARY KEY,owner_did TEXT NOT NULL,invocation_id TEXT NOT NULL,session_id TEXT NOT NULL,run_id TEXT NOT NULL,subject TEXT NOT NULL,digest TEXT NOT NULL,lifecycle TEXT NOT NULL,status TEXT NOT NULL,origin TEXT,receipt_state TEXT NOT NULL DEFAULT 'waiting',receipt_attempts INTEGER NOT NULL DEFAULT 0,next_attempt INTEGER NOT NULL DEFAULT 0,verify_after INTEGER NOT NULL DEFAULT 0,verify_attempts INTEGER NOT NULL DEFAULT 0,created_at INTEGER NOT NULL, UNIQUE(owner_did,invocation_id))`,
|
|
81
|
+
);
|
|
82
|
+
await this.db.run(
|
|
83
|
+
`CREATE INDEX IF NOT EXISTS topic_action_proposals_run ON topic_action_proposals(owner_did,session_id,run_id)`,
|
|
84
|
+
);
|
|
85
|
+
await this.db.run(
|
|
86
|
+
`CREATE INDEX IF NOT EXISTS topic_action_proposals_status ON topic_action_proposals(owner_did,status,verify_after)`,
|
|
87
|
+
);
|
|
88
|
+
await this.db.run(
|
|
89
|
+
`CREATE TABLE IF NOT EXISTS topic_action_steps(proposal_id TEXT NOT NULL,step TEXT NOT NULL,status TEXT NOT NULL,expected TEXT,expected_digest TEXT,executor TEXT,started_at TEXT,reference TEXT,reference_rejected TEXT,receipt TEXT, PRIMARY KEY(proposal_id,step))`,
|
|
90
|
+
);
|
|
91
|
+
const steps = await this.db.exec<{ name: string }>(
|
|
92
|
+
`PRAGMA table_info(topic_action_steps)`,
|
|
93
|
+
);
|
|
94
|
+
if (!steps.some((c) => c.name === 'reference_rejected'))
|
|
95
|
+
await this.db.run(
|
|
96
|
+
`ALTER TABLE topic_action_steps ADD COLUMN reference_rejected TEXT`,
|
|
97
|
+
);
|
|
98
|
+
await this.db.run(
|
|
99
|
+
`CREATE INDEX IF NOT EXISTS topic_action_proposals_receipts ON topic_action_proposals(owner_did,receipt_state,next_attempt)`,
|
|
100
|
+
);
|
|
101
|
+
await this.db.run(
|
|
102
|
+
`CREATE INDEX IF NOT EXISTS topic_action_proposals_created ON topic_action_proposals(owner_did,created_at)`,
|
|
103
|
+
);
|
|
104
|
+
// A card with no room to receipt into never posts one: `unavailable`,
|
|
105
|
+
// so the retention sweep (which waits on pending receipts) can drop it.
|
|
106
|
+
await this.db.run(
|
|
107
|
+
`UPDATE topic_action_proposals SET receipt_state='unavailable' WHERE origin IS NULL AND receipt_state IN ('waiting','pending')`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
/** Every lifecycle write keeps the indexed `status` column in step with the JSON. */
|
|
111
|
+
private async writeLifecycle(
|
|
112
|
+
owner: string,
|
|
113
|
+
id: string,
|
|
114
|
+
lifecycle: TopicActionProposal['lifecycle'],
|
|
115
|
+
receipt?: { state: 'pending' | 'waiting'; nextAttempt: number },
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
await this.db.run(
|
|
118
|
+
receipt
|
|
119
|
+
? // A receipt with nowhere to go (`unavailable`) or already
|
|
120
|
+
// delivered is never scheduled again.
|
|
121
|
+
`UPDATE topic_action_proposals SET lifecycle=?,status=?,receipt_state=CASE WHEN receipt_state IN ('waiting','pending') THEN ? ELSE receipt_state END,next_attempt=? WHERE owner_did=? AND id=?`
|
|
122
|
+
: `UPDATE topic_action_proposals SET lifecycle=?,status=? WHERE owner_did=? AND id=?`,
|
|
123
|
+
receipt
|
|
124
|
+
? [
|
|
125
|
+
JSON.stringify(lifecycle),
|
|
126
|
+
lifecycle.status,
|
|
127
|
+
receipt.state,
|
|
128
|
+
receipt.nextAttempt,
|
|
129
|
+
owner,
|
|
130
|
+
id,
|
|
131
|
+
]
|
|
132
|
+
: [JSON.stringify(lifecycle), lifecycle.status, owner, id],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
async create(
|
|
136
|
+
input: TopicProposalSubject,
|
|
137
|
+
origin: ActionReceiptOrigin | null = null,
|
|
138
|
+
): Promise<TopicActionProposal> {
|
|
139
|
+
const subject = TopicProposalSubjectSchema.parse(
|
|
140
|
+
JSON.parse(canonicalizeIxoJson(input)),
|
|
141
|
+
);
|
|
142
|
+
const digest = await digestTopicProposal(subject);
|
|
143
|
+
return this.db.transaction(async () => {
|
|
144
|
+
const prior = await this.db.get<{ id: string }>(
|
|
145
|
+
`SELECT id FROM topic_action_proposals WHERE owner_did=? AND invocation_id=?`,
|
|
146
|
+
[subject.ownerDid, subject.origin.invocationId],
|
|
147
|
+
);
|
|
148
|
+
if (prior) {
|
|
149
|
+
const p = await this.get(subject.ownerDid, prior.id);
|
|
150
|
+
if (!p) throw new ActionProposalConflict('Proposal missing');
|
|
151
|
+
if (
|
|
152
|
+
(await digestTopicProposal({
|
|
153
|
+
...subject,
|
|
154
|
+
id: p.id,
|
|
155
|
+
createdAt: p.createdAt,
|
|
156
|
+
expiresAt: p.expiresAt,
|
|
157
|
+
})) !== p.digest
|
|
158
|
+
)
|
|
159
|
+
throw new ActionProposalConflict(
|
|
160
|
+
'Invocation already prepared different values',
|
|
161
|
+
);
|
|
162
|
+
return p;
|
|
163
|
+
}
|
|
164
|
+
if (Date.parse(subject.expiresAt) <= this.now())
|
|
165
|
+
throw new ActionProposalConflict('Proposal expired');
|
|
166
|
+
await this.assertCapacity(
|
|
167
|
+
subject.ownerDid,
|
|
168
|
+
subject.origin.sessionId,
|
|
169
|
+
subject.origin.runId,
|
|
170
|
+
);
|
|
171
|
+
const steps: TopicStepId[] =
|
|
172
|
+
subject.action.name === 'create_topic'
|
|
173
|
+
? ['anchor', 'body', 'operation', 'snapshot']
|
|
174
|
+
: ['grant'];
|
|
175
|
+
const lifecycle = {
|
|
176
|
+
status: 'prepared',
|
|
177
|
+
steps: steps.map((step) => ({ step, status: 'pending' })),
|
|
178
|
+
};
|
|
179
|
+
await this.db.run(
|
|
180
|
+
`INSERT INTO topic_action_proposals(id,owner_did,invocation_id,session_id,run_id,subject,digest,lifecycle,status,origin,receipt_state,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
181
|
+
[
|
|
182
|
+
subject.id,
|
|
183
|
+
subject.ownerDid,
|
|
184
|
+
subject.origin.invocationId,
|
|
185
|
+
subject.origin.sessionId,
|
|
186
|
+
subject.origin.runId,
|
|
187
|
+
JSON.stringify(subject),
|
|
188
|
+
digest,
|
|
189
|
+
JSON.stringify(lifecycle),
|
|
190
|
+
'prepared',
|
|
191
|
+
origin ? JSON.stringify(origin) : null,
|
|
192
|
+
origin ? 'waiting' : 'unavailable',
|
|
193
|
+
Date.parse(subject.createdAt),
|
|
194
|
+
],
|
|
195
|
+
);
|
|
196
|
+
for (const step of steps)
|
|
197
|
+
await this.db.run(
|
|
198
|
+
`INSERT INTO topic_action_steps(proposal_id,step,status) VALUES(?,?,'pending')`,
|
|
199
|
+
[subject.id, step],
|
|
200
|
+
);
|
|
201
|
+
return TopicProposalV2Schema.parse({ ...subject, digest, lifecycle });
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* At most `ACTION_PROPOSALS_PER_RUN` cards per run and
|
|
206
|
+
* `ACTION_PROPOSALS_OPEN_PER_OWNER` open ones (prepared in the last 20
|
|
207
|
+
* minutes, or claimed / unknown in the last two hours: the claim window
|
|
208
|
+
* plus the execution window). The owner object runs this before the
|
|
209
|
+
* authority read a refused card would waste; `create` runs it again.
|
|
210
|
+
*/
|
|
211
|
+
async assertCapacity(
|
|
212
|
+
owner: string,
|
|
213
|
+
sessionId: string,
|
|
214
|
+
runId: string,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const now = this.now();
|
|
217
|
+
const run = await this.db.get<{ n: number }>(
|
|
218
|
+
`SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
|
|
219
|
+
[owner, sessionId, runId],
|
|
220
|
+
);
|
|
221
|
+
if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
|
|
222
|
+
throw new ActionProposalConflict(
|
|
223
|
+
'Too many action cards in this request; finish the open ones first',
|
|
224
|
+
);
|
|
225
|
+
const open = await this.db.get<{ n: number }>(
|
|
226
|
+
`SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR (status IN ('claimed','unknown') AND created_at>?))`,
|
|
227
|
+
[
|
|
228
|
+
owner,
|
|
229
|
+
now - OPEN_PREPARED_MS,
|
|
230
|
+
now - OPEN_PREPARED_MS - TOPIC_EXECUTION_WINDOW_MS - 40 * 60_000,
|
|
231
|
+
],
|
|
232
|
+
);
|
|
233
|
+
if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
|
|
234
|
+
throw new ActionProposalConflict(
|
|
235
|
+
'Too many action cards are open; finish or decline them first',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
async get(owner: string, id: string): Promise<TopicActionProposal | null> {
|
|
239
|
+
const row = await this.db.get<{
|
|
240
|
+
subject: string;
|
|
241
|
+
digest: string;
|
|
242
|
+
lifecycle: string;
|
|
243
|
+
}>(
|
|
244
|
+
`SELECT subject,digest,lifecycle FROM topic_action_proposals WHERE owner_did=? AND id=?`,
|
|
245
|
+
[owner, id],
|
|
246
|
+
);
|
|
247
|
+
if (!row) return null;
|
|
248
|
+
const key = `${row.digest}\0${row.lifecycle}\0${row.subject}`;
|
|
249
|
+
const cached = this.validated.get(key);
|
|
250
|
+
if (cached) {
|
|
251
|
+
// Most recently used last.
|
|
252
|
+
this.validated.delete(key);
|
|
253
|
+
this.validated.set(key, cached);
|
|
254
|
+
return structuredClone(cached);
|
|
255
|
+
}
|
|
256
|
+
const p = await assertTopicProposalUnchanged({
|
|
257
|
+
...JSON.parse(row.subject),
|
|
258
|
+
digest: row.digest,
|
|
259
|
+
lifecycle: JSON.parse(row.lifecycle),
|
|
260
|
+
});
|
|
261
|
+
this.validated.set(key, structuredClone(p));
|
|
262
|
+
if (this.validated.size > VALIDATED_CACHE_ENTRIES)
|
|
263
|
+
this.validated.delete(this.validated.keys().next().value!);
|
|
264
|
+
return p;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The local refusals a claim applies (missing, changed, consumed,
|
|
268
|
+
* expired), without writing anything: the owner object runs these before
|
|
269
|
+
* the remote authority read, so a repeated claim is answered at once.
|
|
270
|
+
*/
|
|
271
|
+
async assertClaimable(
|
|
272
|
+
owner: string,
|
|
273
|
+
id: string,
|
|
274
|
+
digest: string,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const p = await this.get(owner, id);
|
|
277
|
+
if (!p) throw new ActionProposalConflict('Proposal missing');
|
|
278
|
+
if (p.digest !== digest)
|
|
279
|
+
throw new ActionProposalConflict('Proposal changed');
|
|
280
|
+
if (p.lifecycle.status !== 'prepared')
|
|
281
|
+
throw new ActionProposalConflict('Proposal already consumed');
|
|
282
|
+
if (Date.parse(p.expiresAt) <= this.now())
|
|
283
|
+
throw new ActionProposalConflict('Proposal expired');
|
|
284
|
+
}
|
|
285
|
+
async replayPreparation(
|
|
286
|
+
owner: string,
|
|
287
|
+
origin: TopicProposalSubject['origin'],
|
|
288
|
+
candidate: unknown,
|
|
289
|
+
evidenceRefs: string[],
|
|
290
|
+
): Promise<TopicActionProposal | null> {
|
|
291
|
+
const row = await this.db.get<{ id: string }>(
|
|
292
|
+
`SELECT id FROM topic_action_proposals WHERE owner_did=? AND invocation_id=?`,
|
|
293
|
+
[owner, origin.invocationId],
|
|
294
|
+
);
|
|
295
|
+
if (!row) return null;
|
|
296
|
+
const prior = await this.get(owner, row.id);
|
|
297
|
+
if (!prior) throw new ActionProposalConflict('Proposal missing');
|
|
298
|
+
const candidateDigest = await digestTopicProposalEffect(candidate);
|
|
299
|
+
if (
|
|
300
|
+
prior.origin.sessionId !== origin.sessionId ||
|
|
301
|
+
prior.origin.runId !== origin.runId ||
|
|
302
|
+
!prior.evidenceRefs.includes(`candidate:${candidateDigest}`) ||
|
|
303
|
+
evidenceRefs.some((ref) => !prior.evidenceRefs.includes(ref))
|
|
304
|
+
)
|
|
305
|
+
throw new ActionProposalConflict(
|
|
306
|
+
'Invocation already prepared different values',
|
|
307
|
+
);
|
|
308
|
+
return prior;
|
|
309
|
+
}
|
|
310
|
+
async forRun(
|
|
311
|
+
owner: string,
|
|
312
|
+
session: string,
|
|
313
|
+
run: string,
|
|
314
|
+
): Promise<TopicActionProposal[]> {
|
|
315
|
+
const rows = await this.db.exec<{ id: string }>(
|
|
316
|
+
`SELECT id FROM topic_action_proposals WHERE owner_did=? AND session_id=? AND run_id=? ORDER BY verify_after,id LIMIT 32`,
|
|
317
|
+
[owner, session, run],
|
|
318
|
+
);
|
|
319
|
+
const values = await Promise.all(rows.map((r) => this.get(owner, r.id)));
|
|
320
|
+
return values.filter((p): p is TopicActionProposal => p !== null);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Cards whose started steps have references the chain or room has not
|
|
324
|
+
* confirmed yet, cooldown passed, attempts not used up; the alarm
|
|
325
|
+
* reconciles these so a card the owner walked away from still completes.
|
|
326
|
+
*/
|
|
327
|
+
async dueVerifications(
|
|
328
|
+
owner: string,
|
|
329
|
+
limit = 2,
|
|
330
|
+
): Promise<TopicActionProposal[]> {
|
|
331
|
+
// A revoked card's started step may still land: its reference is
|
|
332
|
+
// reconciled too, so the effect is receipted and the grant lock released.
|
|
333
|
+
const rows = await this.db.exec<{ id: string }>(
|
|
334
|
+
`SELECT DISTINCT p.id FROM topic_action_proposals p JOIN topic_action_steps s ON s.proposal_id=p.id WHERE p.owner_did=? AND p.status IN ('unknown','revoked') AND p.verify_after<=? AND p.verify_attempts<? AND s.reference IS NOT NULL AND s.status!='verified' ORDER BY p.verify_after,p.id LIMIT ?`,
|
|
335
|
+
[owner, this.now(), VERIFY_MAX_ATTEMPTS, limit],
|
|
336
|
+
);
|
|
337
|
+
const proposals: TopicActionProposal[] = [];
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
try {
|
|
340
|
+
const p = await this.get(owner, row.id);
|
|
341
|
+
if (p) proposals.push(p);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
await this.quarantineVerification(owner, row.id, error);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return proposals;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* A card whose row, or whose reference row, this build cannot read drops
|
|
350
|
+
* out of the verification schedule instead of failing the alarm every
|
|
351
|
+
* tick; its receipt and its place in the grant lock are unaffected.
|
|
352
|
+
*/
|
|
353
|
+
async quarantineVerification(
|
|
354
|
+
owner: string,
|
|
355
|
+
id: string,
|
|
356
|
+
error: unknown,
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
await this.db.run(
|
|
359
|
+
`UPDATE topic_action_proposals SET verify_attempts=? WHERE owner_did=? AND id=?`,
|
|
360
|
+
[VERIFY_MAX_ATTEMPTS, owner, id],
|
|
361
|
+
);
|
|
362
|
+
console.error(
|
|
363
|
+
`[topic-actions] verification of ${id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
async nextVerificationAt(owner: string): Promise<number | undefined> {
|
|
367
|
+
const row = await this.db.get<{ due: number | null }>(
|
|
368
|
+
`SELECT MIN(p.verify_after) AS due FROM topic_action_proposals p JOIN topic_action_steps s ON s.proposal_id=p.id WHERE p.owner_did=? AND p.status IN ('unknown','revoked') AND p.verify_attempts<? AND s.reference IS NOT NULL AND s.status!='verified'`,
|
|
369
|
+
[owner, VERIFY_MAX_ATTEMPTS],
|
|
370
|
+
);
|
|
371
|
+
return row?.due ?? undefined;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Finished cards past retention go, with their steps; unresolved ones
|
|
375
|
+
* (claimed, unknown) after the longer window, long after any lookup of
|
|
376
|
+
* theirs stopped. A card whose receipt is still pending stays.
|
|
377
|
+
*/
|
|
378
|
+
async prune(owner: string): Promise<number> {
|
|
379
|
+
const now = this.now();
|
|
380
|
+
return this.db.transaction(async () => {
|
|
381
|
+
const rows = await this.db.exec<{ id: string }>(
|
|
382
|
+
`SELECT id FROM topic_action_proposals WHERE owner_did=? AND created_at<? AND receipt_state<>'pending' AND (status IN ('prepared','succeeded','failed','rejected','revoked','superseded') OR (created_at<? AND status IN ('claimed','unknown'))) LIMIT 64`,
|
|
383
|
+
[
|
|
384
|
+
owner,
|
|
385
|
+
now - ACTION_PROPOSAL_RETENTION_MS,
|
|
386
|
+
now - ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS,
|
|
387
|
+
],
|
|
388
|
+
);
|
|
389
|
+
for (const row of rows) {
|
|
390
|
+
await this.db.run(
|
|
391
|
+
`DELETE FROM topic_action_steps WHERE proposal_id=?`,
|
|
392
|
+
[row.id],
|
|
393
|
+
);
|
|
394
|
+
await this.db.run(
|
|
395
|
+
`DELETE FROM topic_action_proposals WHERE owner_did=? AND id=?`,
|
|
396
|
+
[owner, row.id],
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return rows.length;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async transition(
|
|
403
|
+
owner: string,
|
|
404
|
+
id: string,
|
|
405
|
+
kind: 'claim' | 'reject' | 'revoke' | 'supersede',
|
|
406
|
+
digest: string,
|
|
407
|
+
): Promise<TopicActionProposal | null> {
|
|
408
|
+
return this.db.transaction(async () => {
|
|
409
|
+
const p = await this.get(owner, id);
|
|
410
|
+
if (!p) return null;
|
|
411
|
+
if (p.digest !== digest)
|
|
412
|
+
throw new ActionProposalConflict('Proposal changed');
|
|
413
|
+
if (
|
|
414
|
+
p.lifecycle.status !== 'prepared' &&
|
|
415
|
+
!(
|
|
416
|
+
kind === 'revoke' &&
|
|
417
|
+
['claimed', 'unknown'].includes(p.lifecycle.status)
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
throw new ActionProposalConflict('Proposal already consumed');
|
|
421
|
+
if (kind === 'claim' && Date.parse(p.expiresAt) <= this.now())
|
|
422
|
+
throw new ActionProposalConflict('Proposal expired');
|
|
423
|
+
const at = new Date(this.now()).toISOString();
|
|
424
|
+
const lifecycle = {
|
|
425
|
+
...p.lifecycle,
|
|
426
|
+
...(kind === 'claim'
|
|
427
|
+
? { status: 'claimed', claimedAt: at }
|
|
428
|
+
: {
|
|
429
|
+
status:
|
|
430
|
+
kind === 'reject'
|
|
431
|
+
? 'rejected'
|
|
432
|
+
: kind === 'revoke'
|
|
433
|
+
? 'revoked'
|
|
434
|
+
: 'superseded',
|
|
435
|
+
endedAt: at,
|
|
436
|
+
...(kind === 'revoke' ? { executionBlockedAt: at } : {}),
|
|
437
|
+
}),
|
|
438
|
+
};
|
|
439
|
+
await this.writeLifecycle(
|
|
440
|
+
owner,
|
|
441
|
+
id,
|
|
442
|
+
TopicProposalV2Schema.shape.lifecycle.parse(lifecycle),
|
|
443
|
+
);
|
|
444
|
+
return TopicProposalV2Schema.parse({ ...p, lifecycle });
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The local refusals `begin` applies, without writing anything: the owner
|
|
449
|
+
* object runs these before the remote authority read, so a card that is
|
|
450
|
+
* not claimed, expired, out of order or in another executor's hands is
|
|
451
|
+
* answered in milliseconds instead of after a ten second budget.
|
|
452
|
+
*/
|
|
453
|
+
async assertBeginnable(
|
|
454
|
+
owner: string,
|
|
455
|
+
id: string,
|
|
456
|
+
input: BeginTopicStep,
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
const p = await this.get(owner, id);
|
|
459
|
+
if (!p) throw new ActionProposalConflict('Proposal missing');
|
|
460
|
+
await this.checkBegin(p, input);
|
|
461
|
+
}
|
|
462
|
+
private async checkBegin(
|
|
463
|
+
p: TopicActionProposal,
|
|
464
|
+
input: BeginTopicStep,
|
|
465
|
+
): Promise<TopicActionProposal['lifecycle']['steps'][number]> {
|
|
466
|
+
if (p.digest !== input.digest)
|
|
467
|
+
throw new ActionProposalConflict('Proposal changed');
|
|
468
|
+
// A claimed or unknown card always carries `claimedAt`; the check is one
|
|
469
|
+
// with the status check and narrows the type.
|
|
470
|
+
if (
|
|
471
|
+
!['claimed', 'unknown'].includes(p.lifecycle.status) ||
|
|
472
|
+
!p.lifecycle.claimedAt
|
|
473
|
+
)
|
|
474
|
+
throw new ActionProposalConflict('Proposal was not claimed');
|
|
475
|
+
// A revoked card fails the status check above; an `unknown` card that is
|
|
476
|
+
// blocked is one whose memo-bound transaction went wrong (`blockExecution`).
|
|
477
|
+
if (p.lifecycle.executionBlockedAt)
|
|
478
|
+
throw new ActionProposalConflict('Execution is blocked');
|
|
479
|
+
const now = this.now();
|
|
480
|
+
// An effect may exist in the world once a step has a reference or is
|
|
481
|
+
// verified; until then the card's own expiry bounds every attempt to
|
|
482
|
+
// start, a permitted-but-never-sent first step included.
|
|
483
|
+
const effects = await this.db.get<{ n: number }>(
|
|
484
|
+
`SELECT COUNT(*) AS n FROM topic_action_steps WHERE proposal_id=? AND (reference IS NOT NULL OR status='verified')`,
|
|
485
|
+
[p.id],
|
|
486
|
+
);
|
|
487
|
+
const started = (effects?.n ?? 0) > 0;
|
|
488
|
+
if (!started && Date.parse(p.expiresAt) <= now)
|
|
489
|
+
throw new ActionProposalConflict('Proposal expired');
|
|
490
|
+
if (
|
|
491
|
+
started &&
|
|
492
|
+
Date.parse(p.lifecycle.claimedAt) + TOPIC_EXECUTION_WINDOW_MS <= now
|
|
493
|
+
)
|
|
494
|
+
throw new ActionProposalConflict('Execution window closed');
|
|
495
|
+
if (
|
|
496
|
+
input.executor.matrixUserId !== p.action.owner.matrixUserId ||
|
|
497
|
+
input.executor.homeserver !== p.action.owner.homeserver
|
|
498
|
+
)
|
|
499
|
+
throw new ActionProposalConflict('Execution identity changed');
|
|
500
|
+
const index = p.lifecycle.steps.findIndex((s) => s.step === input.step);
|
|
501
|
+
if (index < 0) throw new ActionProposalConflict('Invalid step');
|
|
502
|
+
if (p.lifecycle.steps.slice(0, index).some((s) => s.status !== 'verified'))
|
|
503
|
+
throw new ActionProposalConflict('Predecessor is not verified');
|
|
504
|
+
if (input.step === 'grant') await this.assertNoConcurrentGrant(p);
|
|
505
|
+
return p.lifecycle.steps[index]!;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Two cards granting the same account the same role under the same admin,
|
|
509
|
+
* both started and neither confirmed, would overwrite each other on chain
|
|
510
|
+
* while both receipts said "granted". The lock follows the other card's
|
|
511
|
+
* grant STEP, not its status: it holds while that step may still be
|
|
512
|
+
* broadcast (no reference yet, inside its holder's execution window) or
|
|
513
|
+
* may still land (a reference recorded, verification not given up), a
|
|
514
|
+
* revoked card included.
|
|
515
|
+
*/
|
|
516
|
+
private async assertNoConcurrentGrant(p: TopicActionProposal): Promise<void> {
|
|
517
|
+
if (p.action.name !== 'grant_topic_access') return;
|
|
518
|
+
const admin = p.action.adminAddress;
|
|
519
|
+
const keys = new Set(
|
|
520
|
+
p.action.grants.map((g) => `${admin}\0${g.address}\0${g.role}`),
|
|
521
|
+
);
|
|
522
|
+
const now = this.now();
|
|
523
|
+
const rows = await this.db.exec<{
|
|
524
|
+
id: string;
|
|
525
|
+
subject: string;
|
|
526
|
+
lifecycle: string;
|
|
527
|
+
verify_attempts: number;
|
|
528
|
+
reference: string | null;
|
|
529
|
+
}>(
|
|
530
|
+
`SELECT p.id,p.subject,p.lifecycle,p.verify_attempts,s.reference FROM topic_action_proposals p JOIN topic_action_steps s ON s.proposal_id=p.id WHERE p.owner_did=? AND p.id<>? AND s.step='grant' AND s.status='started'`,
|
|
531
|
+
[p.ownerDid, p.id],
|
|
532
|
+
);
|
|
533
|
+
for (const row of rows) {
|
|
534
|
+
let action: TopicProposalSubject['action'];
|
|
535
|
+
let claimedAt: string | undefined;
|
|
536
|
+
try {
|
|
537
|
+
action = TopicProposalSubjectSchema.parse(
|
|
538
|
+
JSON.parse(row.subject),
|
|
539
|
+
).action;
|
|
540
|
+
claimedAt = TopicLifecycleSchema.parse(
|
|
541
|
+
JSON.parse(row.lifecycle),
|
|
542
|
+
).claimedAt;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
// A holder this build cannot read does NOT hold the lock: it can no
|
|
545
|
+
// longer be reconciled or receipted either, and holding would block
|
|
546
|
+
// every grant for that account until retention prunes it. The
|
|
547
|
+
// choice is logged so an operator can see which card it was.
|
|
548
|
+
console.warn(
|
|
549
|
+
`[topic-actions] grant lock skips unreadable card ${row.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
550
|
+
);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (action.name !== 'grant_topic_access') continue;
|
|
554
|
+
if (
|
|
555
|
+
!action.grants.some((g) =>
|
|
556
|
+
keys.has(`${action.adminAddress}\0${g.address}\0${g.role}`),
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
continue;
|
|
560
|
+
const holds = row.reference
|
|
561
|
+
? row.verify_attempts < VERIFY_MAX_ATTEMPTS
|
|
562
|
+
: !!claimedAt &&
|
|
563
|
+
Date.parse(claimedAt) + TOPIC_EXECUTION_WINDOW_MS > now;
|
|
564
|
+
if (holds)
|
|
565
|
+
throw new ActionProposalConflict(
|
|
566
|
+
`Another grant for this account is still in progress (${row.id})`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async begin(
|
|
571
|
+
owner: string,
|
|
572
|
+
id: string,
|
|
573
|
+
input: BeginTopicStep,
|
|
574
|
+
effect: ExpectedTopicEffect,
|
|
575
|
+
): Promise<BeginTopicStepResult | null> {
|
|
576
|
+
const expectedDigest = await digestTopicEffect(effect);
|
|
577
|
+
// The device id binds the permit to its holder and is compared from the
|
|
578
|
+
// journal row only; what goes out in step records and permits is the
|
|
579
|
+
// identity every consumer's contract already knows.
|
|
580
|
+
const executor = {
|
|
581
|
+
matrixUserId: input.executor.matrixUserId,
|
|
582
|
+
homeserver: input.executor.homeserver,
|
|
583
|
+
};
|
|
584
|
+
return this.db.transaction(async () => {
|
|
585
|
+
const p = await this.get(owner, id);
|
|
586
|
+
if (!p) return null;
|
|
587
|
+
const current = await this.checkBegin(p, input);
|
|
588
|
+
if (current.status !== 'pending') {
|
|
589
|
+
// A started step with no reference yet is the same executor's to
|
|
590
|
+
// retry with the SAME frozen effect: a failed Portal pre-check or a
|
|
591
|
+
// wallet cancel must not burn the step until the card expires.
|
|
592
|
+
const row = await this.db.get<{
|
|
593
|
+
status: string;
|
|
594
|
+
expected: string | null;
|
|
595
|
+
expected_digest: string | null;
|
|
596
|
+
executor: string | null;
|
|
597
|
+
reference: string | null;
|
|
598
|
+
}>(
|
|
599
|
+
`SELECT status,expected,expected_digest,executor,reference FROM topic_action_steps WHERE proposal_id=? AND step=?`,
|
|
600
|
+
[id, input.step],
|
|
601
|
+
);
|
|
602
|
+
if (
|
|
603
|
+
row?.status === 'started' &&
|
|
604
|
+
!row.reference &&
|
|
605
|
+
row.expected &&
|
|
606
|
+
row.expected_digest === expectedDigest &&
|
|
607
|
+
row.executor &&
|
|
608
|
+
canonicalizeIxoJson(JSON.parse(row.executor)) ===
|
|
609
|
+
canonicalizeIxoJson(input.executor)
|
|
610
|
+
)
|
|
611
|
+
return {
|
|
612
|
+
status: 'permitted',
|
|
613
|
+
permit: {
|
|
614
|
+
proposalId: id,
|
|
615
|
+
digest: p.digest,
|
|
616
|
+
step: input.step,
|
|
617
|
+
executor,
|
|
618
|
+
expectedDigest,
|
|
619
|
+
effect: ExpectedTopicEffectSchema.parse(JSON.parse(row.expected)),
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
return { status: 'already_started', proposal: p };
|
|
623
|
+
}
|
|
624
|
+
const startedAt = new Date(this.now()).toISOString();
|
|
625
|
+
const record = {
|
|
626
|
+
step: input.step,
|
|
627
|
+
status: 'started' as const,
|
|
628
|
+
startedAt,
|
|
629
|
+
expectedDigest,
|
|
630
|
+
executor,
|
|
631
|
+
};
|
|
632
|
+
const changed = await this.db.run(
|
|
633
|
+
`UPDATE topic_action_steps SET status='started',expected=?,expected_digest=?,executor=?,started_at=? WHERE proposal_id=? AND step=? AND status='pending'`,
|
|
634
|
+
[
|
|
635
|
+
JSON.stringify(effect),
|
|
636
|
+
expectedDigest,
|
|
637
|
+
JSON.stringify(input.executor),
|
|
638
|
+
startedAt,
|
|
639
|
+
id,
|
|
640
|
+
input.step,
|
|
641
|
+
],
|
|
642
|
+
);
|
|
643
|
+
if (changed.changes !== 1)
|
|
644
|
+
throw new ActionProposalConflict('Step already started');
|
|
645
|
+
const lifecycle = TopicProposalV2Schema.shape.lifecycle.parse({
|
|
646
|
+
...p.lifecycle,
|
|
647
|
+
status: 'unknown',
|
|
648
|
+
steps: p.lifecycle.steps.map((s) =>
|
|
649
|
+
s.step === input.step ? record : s,
|
|
650
|
+
),
|
|
651
|
+
});
|
|
652
|
+
await this.writeLifecycle(owner, id, lifecycle);
|
|
653
|
+
return {
|
|
654
|
+
status: 'permitted',
|
|
655
|
+
permit: {
|
|
656
|
+
proposalId: id,
|
|
657
|
+
digest: p.digest,
|
|
658
|
+
step: input.step,
|
|
659
|
+
executor,
|
|
660
|
+
expectedDigest,
|
|
661
|
+
effect,
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/** One reconciliation per cooldown; the wait doubles with every attempt (see `store.ts`). */
|
|
667
|
+
async claimVerification(owner: string, id: string): Promise<boolean> {
|
|
668
|
+
const now = this.now();
|
|
669
|
+
const row = await this.db.get<{ verify_attempts: number }>(
|
|
670
|
+
`SELECT verify_attempts FROM topic_action_proposals WHERE owner_did=? AND id=? AND verify_after<=?`,
|
|
671
|
+
[owner, id, now],
|
|
672
|
+
);
|
|
673
|
+
if (!row) return false;
|
|
674
|
+
const result = await this.db.run(
|
|
675
|
+
`UPDATE topic_action_proposals SET verify_after=?,verify_attempts=verify_attempts+1 WHERE owner_did=? AND id=? AND verify_after<=?`,
|
|
676
|
+
[
|
|
677
|
+
now +
|
|
678
|
+
backoff(
|
|
679
|
+
VERIFY_RETRY_BASE_MS,
|
|
680
|
+
VERIFY_RETRY_MAX_MS,
|
|
681
|
+
row.verify_attempts,
|
|
682
|
+
),
|
|
683
|
+
owner,
|
|
684
|
+
id,
|
|
685
|
+
now,
|
|
686
|
+
],
|
|
687
|
+
);
|
|
688
|
+
return result.changes === 1;
|
|
689
|
+
}
|
|
690
|
+
async hasReference(
|
|
691
|
+
owner: string,
|
|
692
|
+
id: string,
|
|
693
|
+
step: TopicStepId,
|
|
694
|
+
): Promise<boolean> {
|
|
695
|
+
if (!(await this.get(owner, id))) return false;
|
|
696
|
+
const row = await this.db.get<{ reference: string | null }>(
|
|
697
|
+
`SELECT reference FROM topic_action_steps WHERE proposal_id=? AND step=?`,
|
|
698
|
+
[id, step],
|
|
699
|
+
);
|
|
700
|
+
return !!row?.reference;
|
|
701
|
+
}
|
|
702
|
+
async recordReference(
|
|
703
|
+
owner: string,
|
|
704
|
+
id: string,
|
|
705
|
+
reference: TopicReceiptReferences['references'][number],
|
|
706
|
+
): Promise<ExpectedTopicEffect> {
|
|
707
|
+
return this.db.transaction(async () => {
|
|
708
|
+
const p = await this.get(owner, id);
|
|
709
|
+
if (!p) throw new ActionProposalConflict('Proposal missing');
|
|
710
|
+
const row = await this.db.get<{
|
|
711
|
+
status: string;
|
|
712
|
+
expected: string | null;
|
|
713
|
+
reference: string | null;
|
|
714
|
+
reference_rejected: string | null;
|
|
715
|
+
}>(
|
|
716
|
+
`SELECT status,expected,reference,reference_rejected FROM topic_action_steps WHERE proposal_id=? AND step=?`,
|
|
717
|
+
[id, reference.step],
|
|
718
|
+
);
|
|
719
|
+
if (!row || row.status === 'pending' || !row.expected)
|
|
720
|
+
throw new ActionProposalConflict('Step was not started');
|
|
721
|
+
const same = (stored: string) =>
|
|
722
|
+
canonicalizeIxoJson(JSON.parse(stored)) ===
|
|
723
|
+
canonicalizeIxoJson(reference);
|
|
724
|
+
if (row.reference) {
|
|
725
|
+
if (!same(row.reference))
|
|
726
|
+
throw new ActionProposalConflict('Step reference already recorded');
|
|
727
|
+
} else {
|
|
728
|
+
if (row.reference_rejected && same(row.reference_rejected))
|
|
729
|
+
throw new ActionProposalConflict('Step reference was rejected');
|
|
730
|
+
await this.db.run(
|
|
731
|
+
`UPDATE topic_action_steps SET reference=? WHERE proposal_id=? AND step=?`,
|
|
732
|
+
[JSON.stringify(reference), id, reference.step],
|
|
733
|
+
);
|
|
734
|
+
// A reference just recorded gets a fresh verification schedule, the
|
|
735
|
+
// attempts an earlier step or a rejected reference spent included.
|
|
736
|
+
await this.db.run(
|
|
737
|
+
`UPDATE topic_action_proposals SET verify_after=?,verify_attempts=0 WHERE owner_did=? AND id=?`,
|
|
738
|
+
[this.now(), owner, id],
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return ExpectedTopicEffectSchema.parse(JSON.parse(row.expected));
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* A recorded reference the verifier proved is another transaction is set
|
|
746
|
+
* aside: the step stays started with no reference (so it is no longer
|
|
747
|
+
* scheduled and the grant lock follows the execution window), the owner
|
|
748
|
+
* may record the right one, and the rejected one is refused again.
|
|
749
|
+
*/
|
|
750
|
+
async rejectReference(
|
|
751
|
+
owner: string,
|
|
752
|
+
id: string,
|
|
753
|
+
reference: TopicReceiptReferences['references'][number],
|
|
754
|
+
): Promise<void> {
|
|
755
|
+
const rejected = await this.db.transaction(async () => {
|
|
756
|
+
if (!(await this.get(owner, id))) return false;
|
|
757
|
+
const row = await this.db.get<{ reference: string | null }>(
|
|
758
|
+
`SELECT reference FROM topic_action_steps WHERE proposal_id=? AND step=? AND status='started'`,
|
|
759
|
+
[id, reference.step],
|
|
760
|
+
);
|
|
761
|
+
if (
|
|
762
|
+
!row?.reference ||
|
|
763
|
+
canonicalizeIxoJson(JSON.parse(row.reference)) !==
|
|
764
|
+
canonicalizeIxoJson(reference)
|
|
765
|
+
)
|
|
766
|
+
return false;
|
|
767
|
+
await this.db.run(
|
|
768
|
+
`UPDATE topic_action_steps SET reference=NULL,reference_rejected=? WHERE proposal_id=? AND step=?`,
|
|
769
|
+
[row.reference, id, reference.step],
|
|
770
|
+
);
|
|
771
|
+
return true;
|
|
772
|
+
});
|
|
773
|
+
if (rejected)
|
|
774
|
+
console.warn(
|
|
775
|
+
`[topic-actions] ${id} ${reference.step} reference set aside: the verifier proved it is another transaction`,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* A transaction carrying this card's memo but other messages is an effect
|
|
780
|
+
* the owner bound to the card that went wrong. It is NOT set aside: the
|
|
781
|
+
* hash stays listed for the owner, execution is blocked so the step is
|
|
782
|
+
* never re-permitted over it, and verification stops. The card stays
|
|
783
|
+
* `unknown`; the owner revokes it and prepares another.
|
|
784
|
+
*/
|
|
785
|
+
async blockExecution(owner: string, id: string, reason: string) {
|
|
786
|
+
const blocked = await this.db.transaction(async () => {
|
|
787
|
+
const p = await this.get(owner, id);
|
|
788
|
+
if (!p) return false;
|
|
789
|
+
// Verification stops either way: a revoked card (blocked already) must
|
|
790
|
+
// not keep reading the chain for an effect it cannot own, nor hold the
|
|
791
|
+
// grant lock for the rest of its window.
|
|
792
|
+
await this.db.run(
|
|
793
|
+
`UPDATE topic_action_proposals SET verify_attempts=? WHERE owner_did=? AND id=?`,
|
|
794
|
+
[VERIFY_MAX_ATTEMPTS, owner, id],
|
|
795
|
+
);
|
|
796
|
+
if (p.lifecycle.executionBlockedAt) return false;
|
|
797
|
+
await this.writeLifecycle(
|
|
798
|
+
owner,
|
|
799
|
+
id,
|
|
800
|
+
TopicProposalV2Schema.shape.lifecycle.parse({
|
|
801
|
+
...p.lifecycle,
|
|
802
|
+
executionBlockedAt: new Date(this.now()).toISOString(),
|
|
803
|
+
}),
|
|
804
|
+
);
|
|
805
|
+
return true;
|
|
806
|
+
});
|
|
807
|
+
if (blocked)
|
|
808
|
+
console.error(`[topic-actions] ${id} execution blocked: ${reason}`);
|
|
809
|
+
}
|
|
810
|
+
async verifyStep(
|
|
811
|
+
owner: string,
|
|
812
|
+
id: string,
|
|
813
|
+
step: TopicStepId,
|
|
814
|
+
receipt: VerifiedEffect,
|
|
815
|
+
): Promise<TopicActionProposal> {
|
|
816
|
+
return this.db.transaction(async () => {
|
|
817
|
+
const p = await this.get(owner, id);
|
|
818
|
+
if (!p) throw new ActionProposalConflict('Proposal missing');
|
|
819
|
+
const current = p.lifecycle.steps.find((s) => s.step === step);
|
|
820
|
+
if (!current || current.status === 'pending')
|
|
821
|
+
throw new ActionProposalConflict('Step was not started');
|
|
822
|
+
if (current.status === 'verified') {
|
|
823
|
+
if (
|
|
824
|
+
canonicalizeIxoJson(current.receipt) !== canonicalizeIxoJson(receipt)
|
|
825
|
+
)
|
|
826
|
+
throw new ActionProposalConflict('Verified step changed');
|
|
827
|
+
return p;
|
|
828
|
+
}
|
|
829
|
+
const steps = p.lifecycle.steps.map((s) =>
|
|
830
|
+
s.step === step ? { step, status: 'verified' as const, receipt } : s,
|
|
831
|
+
);
|
|
832
|
+
let finalReceipt: TopicActionReceipt | undefined;
|
|
833
|
+
if (p.action.name === 'grant_topic_access' && receipt.kind === 'chain')
|
|
834
|
+
finalReceipt =
|
|
835
|
+
receipt.code === 0
|
|
836
|
+
? {
|
|
837
|
+
kind: 'topic-access-granted',
|
|
838
|
+
transactionHash: receipt.transactionHash,
|
|
839
|
+
chainId: receipt.chainId,
|
|
840
|
+
height: receipt.height,
|
|
841
|
+
code: 0,
|
|
842
|
+
verifiedAt: receipt.verifiedAt,
|
|
843
|
+
}
|
|
844
|
+
: {
|
|
845
|
+
kind: 'topic-access-failed',
|
|
846
|
+
transactionHash: receipt.transactionHash,
|
|
847
|
+
chainId: receipt.chainId,
|
|
848
|
+
height: receipt.height,
|
|
849
|
+
code: receipt.code,
|
|
850
|
+
verifiedAt: receipt.verifiedAt,
|
|
851
|
+
};
|
|
852
|
+
if (
|
|
853
|
+
p.action.name === 'create_topic' &&
|
|
854
|
+
steps.every((s) => s.status === 'verified')
|
|
855
|
+
) {
|
|
856
|
+
const event = (key: TopicStepId) => {
|
|
857
|
+
const s = steps.find((s) => s.step === key);
|
|
858
|
+
if (s?.status !== 'verified' || s.receipt.kind !== 'matrix')
|
|
859
|
+
throw new ActionProposalConflict('Missing Matrix receipt');
|
|
860
|
+
return s.receipt.eventId;
|
|
861
|
+
};
|
|
862
|
+
const { topicIdFromPlan } = await import('./topic-effects');
|
|
863
|
+
finalReceipt = {
|
|
864
|
+
kind: 'topic-created',
|
|
865
|
+
roomId: p.action.roomId,
|
|
866
|
+
topicId: topicIdFromPlan(p.action.plan),
|
|
867
|
+
rootEventId: event('anchor'),
|
|
868
|
+
bodyEventId: event('body'),
|
|
869
|
+
operationEventId: event('operation'),
|
|
870
|
+
stateEventId: event('snapshot'),
|
|
871
|
+
verifiedAt: receipt.verifiedAt,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
const lifecycle = {
|
|
875
|
+
...p.lifecycle,
|
|
876
|
+
steps,
|
|
877
|
+
...(finalReceipt
|
|
878
|
+
? {
|
|
879
|
+
status:
|
|
880
|
+
finalReceipt.kind === 'topic-access-failed'
|
|
881
|
+
? 'failed'
|
|
882
|
+
: 'succeeded',
|
|
883
|
+
receipt: finalReceipt,
|
|
884
|
+
}
|
|
885
|
+
: { status: p.lifecycle.executionBlockedAt ? 'revoked' : 'unknown' }),
|
|
886
|
+
};
|
|
887
|
+
await this.db.run(
|
|
888
|
+
`UPDATE topic_action_steps SET status='verified',receipt=? WHERE proposal_id=? AND step=?`,
|
|
889
|
+
[JSON.stringify(receipt), id, step],
|
|
890
|
+
);
|
|
891
|
+
await this.writeLifecycle(
|
|
892
|
+
owner,
|
|
893
|
+
id,
|
|
894
|
+
TopicProposalV2Schema.shape.lifecycle.parse(lifecycle),
|
|
895
|
+
{
|
|
896
|
+
state: finalReceipt ? 'pending' : 'waiting',
|
|
897
|
+
nextAttempt: this.now(),
|
|
898
|
+
},
|
|
899
|
+
);
|
|
900
|
+
return TopicProposalV2Schema.parse({ ...p, lifecycle });
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
async references(
|
|
904
|
+
owner: string,
|
|
905
|
+
id: string,
|
|
906
|
+
): Promise<TopicReceiptReferences['references']> {
|
|
907
|
+
if (!(await this.get(owner, id))) return [];
|
|
908
|
+
const rows = await this.db.exec<{ reference: string }>(
|
|
909
|
+
`SELECT reference FROM topic_action_steps WHERE proposal_id=? AND reference IS NOT NULL AND status!='verified' ORDER BY rowid`,
|
|
910
|
+
[id],
|
|
911
|
+
);
|
|
912
|
+
if (!rows.length) return [];
|
|
913
|
+
return TopicReceiptReferencesSchema.parse({
|
|
914
|
+
v: 2,
|
|
915
|
+
references: rows.map((r) => JSON.parse(r.reference)),
|
|
916
|
+
}).references;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Receipts due now, at most four, each moved to its next attempt (or
|
|
920
|
+
* abandoned on the last) before it is handed out; a row this build can
|
|
921
|
+
* no longer read is quarantined rather than retried for ever. Same
|
|
922
|
+
* schedule as the transfer receipts in `store.ts`.
|
|
923
|
+
*/
|
|
924
|
+
async takeReceipts(
|
|
925
|
+
owner: string,
|
|
926
|
+
): Promise<{ proposal: TopicActionProposal; origin: ActionReceiptOrigin }[]> {
|
|
927
|
+
return this.db.transaction(async () => {
|
|
928
|
+
const now = this.now();
|
|
929
|
+
const rows = await this.db.exec<{
|
|
930
|
+
id: string;
|
|
931
|
+
origin: string;
|
|
932
|
+
receipt_attempts: number;
|
|
933
|
+
}>(
|
|
934
|
+
`SELECT id,origin,receipt_attempts FROM topic_action_proposals WHERE owner_did=? AND receipt_state='pending' AND origin IS NOT NULL AND next_attempt<=? ORDER BY next_attempt,id LIMIT 4`,
|
|
935
|
+
[owner, now],
|
|
936
|
+
);
|
|
937
|
+
const results = [];
|
|
938
|
+
for (const row of rows) {
|
|
939
|
+
let proposal: TopicActionProposal | null;
|
|
940
|
+
try {
|
|
941
|
+
proposal = await this.get(owner, row.id);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
await this.db.run(
|
|
944
|
+
`UPDATE topic_action_proposals SET receipt_state='quarantined' WHERE owner_did=? AND id=?`,
|
|
945
|
+
[owner, row.id],
|
|
946
|
+
);
|
|
947
|
+
console.error(
|
|
948
|
+
`[topic-actions] receipt ${row.id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
|
|
949
|
+
);
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (!proposal) continue;
|
|
953
|
+
const attempts = row.receipt_attempts + 1;
|
|
954
|
+
await this.db.run(
|
|
955
|
+
attempts >= RECEIPT_MAX_ATTEMPTS
|
|
956
|
+
? `UPDATE topic_action_proposals SET receipt_state='abandoned',receipt_attempts=? WHERE owner_did=? AND id=?`
|
|
957
|
+
: `UPDATE topic_action_proposals SET next_attempt=?,receipt_attempts=? WHERE owner_did=? AND id=?`,
|
|
958
|
+
attempts >= RECEIPT_MAX_ATTEMPTS
|
|
959
|
+
? [attempts, owner, row.id]
|
|
960
|
+
: [
|
|
961
|
+
now +
|
|
962
|
+
backoff(
|
|
963
|
+
RECEIPT_RETRY_BASE_MS,
|
|
964
|
+
RECEIPT_RETRY_MAX_MS,
|
|
965
|
+
row.receipt_attempts,
|
|
966
|
+
),
|
|
967
|
+
attempts,
|
|
968
|
+
owner,
|
|
969
|
+
row.id,
|
|
970
|
+
],
|
|
971
|
+
);
|
|
972
|
+
results.push({ proposal, origin: JSON.parse(row.origin) });
|
|
973
|
+
}
|
|
974
|
+
return results;
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
async receiptMirrored(owner: string, id: string): Promise<void> {
|
|
978
|
+
await this.db.run(
|
|
979
|
+
`UPDATE topic_action_proposals SET receipt_state='delivered' WHERE owner_did=? AND id=? AND receipt_state IN ('pending','abandoned')`,
|
|
980
|
+
[owner, id],
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
async nextReceiptAttempt(owner: string): Promise<number | undefined> {
|
|
984
|
+
const r = await this.db.get<{ due: number | null }>(
|
|
985
|
+
`SELECT MIN(next_attempt) AS due FROM topic_action_proposals WHERE owner_did=? AND receipt_state='pending' AND origin IS NOT NULL`,
|
|
986
|
+
[owner],
|
|
987
|
+
);
|
|
988
|
+
return r?.due ?? undefined;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
export async function digestTopicProposalEffect(
|
|
992
|
+
effect: unknown,
|
|
993
|
+
): Promise<string> {
|
|
994
|
+
const bytes = new TextEncoder().encode(canonicalizeIxoJson(effect));
|
|
995
|
+
return Array.from(
|
|
996
|
+
new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
|
|
997
|
+
(b) => b.toString(16).padStart(2, '0'),
|
|
998
|
+
).join('');
|
|
999
|
+
}
|