@agnt-sdk/simon-server 0.0.0-stage → 0.1.1
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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +317 -2
- package/dist/account.d.ts +59 -0
- package/dist/account.js +244 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.js +96 -0
- package/dist/config.d.ts +219 -0
- package/dist/config.js +300 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.js +2 -0
- package/dist/continuity.d.ts +35 -0
- package/dist/continuity.js +74 -0
- package/dist/deliver.d.ts +22 -0
- package/dist/deliver.js +208 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +11 -0
- package/dist/extensions.d.ts +216 -0
- package/dist/extensions.js +75 -0
- package/dist/fanout.d.ts +32 -0
- package/dist/fanout.js +79 -0
- package/dist/http.d.ts +69 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +12 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +41 -0
- package/dist/net/dns.d.ts +31 -0
- package/dist/net/dns.js +100 -0
- package/dist/net/fetch.d.ts +48 -0
- package/dist/net/fetch.js +173 -0
- package/dist/net/ip.d.ts +38 -0
- package/dist/net/ip.js +159 -0
- package/dist/net/psl.d.ts +22 -0
- package/dist/net/psl.js +91 -0
- package/dist/net/public-suffixes.d.ts +10 -0
- package/dist/net/public-suffixes.js +6885 -0
- package/dist/notice.d.ts +5 -0
- package/dist/notice.js +61 -0
- package/dist/outbox.d.ts +14 -0
- package/dist/outbox.js +356 -0
- package/dist/parts.d.ts +29 -0
- package/dist/parts.js +443 -0
- package/dist/public.d.ts +43 -0
- package/dist/public.js +134 -0
- package/dist/published.d.ts +3 -0
- package/dist/published.js +140 -0
- package/dist/rate.d.ts +28 -0
- package/dist/rate.js +77 -0
- package/dist/readers.d.ts +31 -0
- package/dist/readers.js +48 -0
- package/dist/register.d.ts +4 -0
- package/dist/register.js +401 -0
- package/dist/resolver.d.ts +140 -0
- package/dist/resolver.js +386 -0
- package/dist/send.d.ts +30 -0
- package/dist/send.js +216 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +234 -0
- package/dist/signal.d.ts +5 -0
- package/dist/signal.js +137 -0
- package/dist/store/contract.d.ts +4 -0
- package/dist/store/contract.js +1177 -0
- package/dist/store/sqlite-migration.d.ts +82 -0
- package/dist/store/sqlite-migration.js +1019 -0
- package/dist/store/sqlite.d.ts +108 -0
- package/dist/store/sqlite.js +992 -0
- package/dist/store/store.d.ts +400 -0
- package/dist/store/store.js +23 -0
- package/dist/sync.d.ts +19 -0
- package/dist/sync.js +199 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +87 -0
- package/dist/webhooks.d.ts +10 -0
- package/dist/webhooks.js +162 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +33 -0
- package/package.json +57 -4
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the server keeps, behind one interface. The sqlite store implements it; any other store (an
|
|
3
|
+
* application may write one for its own database) proves itself with the contract suite in `contract.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Rules every implementation keeps:
|
|
6
|
+
* - **No clock.** Every time is passed in, as an ISO timestamp or a Date, so tests move time freely.
|
|
7
|
+
* - **Uniqueness is part of the contract,** stated on each method that depends on it, and atomic: two
|
|
8
|
+
* calls racing on the same key never both win. A database index is one way to keep it; it is not the
|
|
9
|
+
* contract.
|
|
10
|
+
* - **A method is one change.** What a method writes is all there or none of it, so a crash between two
|
|
11
|
+
* calls never leaves half of one behind.
|
|
12
|
+
* - **Queues are claimed, not read.** Work the server does later (deliveries, arrivals, inbox events, part
|
|
13
|
+
* fetches, webhook posts) is a row it settles explicitly; nothing is lost by a crash, and a crash at worst
|
|
14
|
+
* repeats work.
|
|
15
|
+
* - **Retention is explicit.** Nothing expires except through `sweep`, which says what it removes.
|
|
16
|
+
* - Methods return copies; mutating a returned record changes nothing stored.
|
|
17
|
+
*/
|
|
18
|
+
import type { AnyMessage, Claim, Hints, KeyEntry, SignedClaims } from '@agnt-sdk/simon-protocol';
|
|
19
|
+
/** Reference-host admission limits, separate from protocol limits. Accepted retractions are never evicted. */
|
|
20
|
+
export interface RetractionCapacity {
|
|
21
|
+
maxRecords: number;
|
|
22
|
+
maxPerAuthor: number;
|
|
23
|
+
maxBytes: number;
|
|
24
|
+
}
|
|
25
|
+
export declare class StoreCapacityError extends Error {
|
|
26
|
+
readonly code = "store_capacity";
|
|
27
|
+
constructor();
|
|
28
|
+
}
|
|
29
|
+
export declare function resolveRetractionCapacity(input?: Partial<RetractionCapacity>): RetractionCapacity;
|
|
30
|
+
/**
|
|
31
|
+
* The most keys, revoked ones included, that adding a key may leave an agent with: four published
|
|
32
|
+
* documents' worth (PROTOCOL.md section 4 lists at most 32). A revoked key is kept so it never returns, which
|
|
33
|
+
* leaves history unbounded without a cap, and a Mongo agent document is limited to 16 MiB. Recovery is never
|
|
34
|
+
* refused for it: a recovery replaces every key with one, and refusing it would let a key holder block it.
|
|
35
|
+
*/
|
|
36
|
+
export declare const MAX_STORED_KEYS = 128;
|
|
37
|
+
/** A key of an agent here: its entry in the agent's document, and a label for its own clients, never published. */
|
|
38
|
+
export interface AgentKey extends KeyEntry {
|
|
39
|
+
label?: string;
|
|
40
|
+
}
|
|
41
|
+
/** An agent registered here. Its address and kind are fixed for the life of the address. */
|
|
42
|
+
export interface Agent {
|
|
43
|
+
/** Private current authorization after recovery; absent for legacy temporal-key authorization. */
|
|
44
|
+
authorizedKeys?: string[];
|
|
45
|
+
/** Private Store authority epoch; never part of agent documents or account wire responses. */
|
|
46
|
+
authorityVersion?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Private recovery epoch, advanced only when a recovery ends or authority over the address is reset:
|
|
49
|
+
* a recovery cancelled, completed or lapsed, a principal recovery, a retirement. Adding or revoking a key
|
|
50
|
+
* never advances it, so a key holder cannot void a mailbox code (register.ts). Absent means zero.
|
|
51
|
+
*/
|
|
52
|
+
recoveryEpoch?: number;
|
|
53
|
+
/** Private last key cancellation, used by the host's recovery cooldown. */
|
|
54
|
+
recoveryKeyCancelledAt?: string;
|
|
55
|
+
address: string;
|
|
56
|
+
kind: 'solo' | 'paired';
|
|
57
|
+
/** The principal's identifier (the `for` header) of a paired agent; null for a solo one. */
|
|
58
|
+
principal: string | null;
|
|
59
|
+
/** Every key ever added, revoked ones included, oldest first. */
|
|
60
|
+
keys: AgentKey[];
|
|
61
|
+
/** Whether the address was bound by a code sent to it, so a code can recover it. */
|
|
62
|
+
mailbox: boolean;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
/**
|
|
65
|
+
* When the address retired: it never serves anyone again and every key is revoked. Publishing optional
|
|
66
|
+
* retirement evidence is host policy; private hosting may withhold it.
|
|
67
|
+
*/
|
|
68
|
+
retiredAt: string | null;
|
|
69
|
+
/** The key principal's unpairing that retired it, when one did, served in its retired document. */
|
|
70
|
+
unpairing?: string | null;
|
|
71
|
+
}
|
|
72
|
+
/** A message held here: written by an agent here, or accepted from another server. */
|
|
73
|
+
export interface StoredMessage {
|
|
74
|
+
id: string;
|
|
75
|
+
message: AnyMessage;
|
|
76
|
+
/** The origin of the server that carried it; this server's own for a message written here. */
|
|
77
|
+
carrier: string;
|
|
78
|
+
/** Claims about the author: made by this server, or accepted from the carrier, as judged here. */
|
|
79
|
+
claims: Claim[];
|
|
80
|
+
/** The carrier's claims exactly as it signed them for this message (spec section 6.2), or null with none. */
|
|
81
|
+
signedClaims: SignedClaims | null;
|
|
82
|
+
receivedAt: string;
|
|
83
|
+
/** When `sweep` removes it, with everything tied to it. */
|
|
84
|
+
expiresAt: string;
|
|
85
|
+
}
|
|
86
|
+
/** A message in an agent's sync stream: one it received, or one it wrote here. */
|
|
87
|
+
export interface SyncEntry {
|
|
88
|
+
/** Its position in the stream. */
|
|
89
|
+
cursor: number;
|
|
90
|
+
address: string;
|
|
91
|
+
id: string;
|
|
92
|
+
message: AnyMessage;
|
|
93
|
+
carrier: string;
|
|
94
|
+
claims: Claim[];
|
|
95
|
+
signedClaims: SignedClaims | null;
|
|
96
|
+
/** When it entered the stream. */
|
|
97
|
+
receivedAt: string;
|
|
98
|
+
}
|
|
99
|
+
/** A signal held for an agent here (CLIENT.md Signals), in its place in the agent's sync stream. */
|
|
100
|
+
export interface HeldSignal {
|
|
101
|
+
cursor: number;
|
|
102
|
+
type: string;
|
|
103
|
+
from: string;
|
|
104
|
+
about: string;
|
|
105
|
+
at: string;
|
|
106
|
+
}
|
|
107
|
+
/** A page of an agent's sync stream. */
|
|
108
|
+
export interface SyncPage {
|
|
109
|
+
messages: SyncEntry[];
|
|
110
|
+
signals: HeldSignal[];
|
|
111
|
+
/** The last position the page covers: the next page starts after it. */
|
|
112
|
+
cursor: number;
|
|
113
|
+
more: boolean;
|
|
114
|
+
}
|
|
115
|
+
/** A URL that wakes an agent here when something new reaches it (CLIENT.md sync, "Webhooks"). */
|
|
116
|
+
export interface Webhook {
|
|
117
|
+
id: string;
|
|
118
|
+
address: string;
|
|
119
|
+
url: string;
|
|
120
|
+
/** The hash of the code posted to the URL, which the agent hands back to verify it. */
|
|
121
|
+
codeHash: string;
|
|
122
|
+
verified: boolean;
|
|
123
|
+
createdAt: string;
|
|
124
|
+
/** When posts to it began failing, without an answer since; null while it answers. */
|
|
125
|
+
failingSince: string | null;
|
|
126
|
+
}
|
|
127
|
+
/** A page of a listing, newest first: each record with its cursor, the message's id, which continues after it. */
|
|
128
|
+
export interface Page {
|
|
129
|
+
records: {
|
|
130
|
+
cursor: string;
|
|
131
|
+
message: StoredMessage;
|
|
132
|
+
}[];
|
|
133
|
+
more: boolean;
|
|
134
|
+
}
|
|
135
|
+
/** Delivery of one message to the recipients on one domain. */
|
|
136
|
+
export interface Delivery {
|
|
137
|
+
id: string;
|
|
138
|
+
domain: string;
|
|
139
|
+
recipients: string[];
|
|
140
|
+
/** The hints for these recipients, held only until the delivery ends. */
|
|
141
|
+
hints: Hints;
|
|
142
|
+
/** The server the domain last resolved to; null until it resolved. */
|
|
143
|
+
server: string | null;
|
|
144
|
+
status: 'pending' | 'delivered' | 'failed';
|
|
145
|
+
/** Why it failed, or why the last attempt did not succeed: a resolution outcome or an error code. */
|
|
146
|
+
reason: string | null;
|
|
147
|
+
attempts: number;
|
|
148
|
+
nextAttemptAt: string;
|
|
149
|
+
createdAt: string;
|
|
150
|
+
/** No attempt starts after this (spec section 6.3); the delivery then fails as `expired`. */
|
|
151
|
+
deadline: string;
|
|
152
|
+
}
|
|
153
|
+
/** A message from another server (or written here), waiting to be offered to one recipient here. */
|
|
154
|
+
export interface QueuedArrival {
|
|
155
|
+
id: string;
|
|
156
|
+
recipient: string;
|
|
157
|
+
/** This recipient's hints. */
|
|
158
|
+
hints: Record<string, string>;
|
|
159
|
+
attempts: number;
|
|
160
|
+
dueAt: string;
|
|
161
|
+
}
|
|
162
|
+
/** Bytes of a part to fetch from the server that carried a message naming them. */
|
|
163
|
+
export interface PartFetch {
|
|
164
|
+
/** Changes on every row mutation; absent only on an initial caller-created enqueue record. */
|
|
165
|
+
revision?: number;
|
|
166
|
+
/** A newly queued row has a fresh incarnation, so a stale pre-completion read never claims its successor. */
|
|
167
|
+
generation?: string;
|
|
168
|
+
sha256: string;
|
|
169
|
+
carrier: string;
|
|
170
|
+
size: number;
|
|
171
|
+
attempts: number;
|
|
172
|
+
dueAt: string;
|
|
173
|
+
}
|
|
174
|
+
/** Where bytes came from: the agent here that uploaded them, or the carrier they were fetched from. */
|
|
175
|
+
export type PartSource = {
|
|
176
|
+
uploader: string;
|
|
177
|
+
} | {
|
|
178
|
+
carrier: string;
|
|
179
|
+
};
|
|
180
|
+
export interface SweepCounts {
|
|
181
|
+
messages: number;
|
|
182
|
+
parts: number;
|
|
183
|
+
marks: number;
|
|
184
|
+
signals: number;
|
|
185
|
+
}
|
|
186
|
+
/** Synchronous final public-document capacity check, evaluated inside the mutation lock/CAS. */
|
|
187
|
+
export type AgentAdmission = (candidate: Agent) => boolean;
|
|
188
|
+
export interface Store {
|
|
189
|
+
/** Insert, unless the address or any of its keys is already taken. */
|
|
190
|
+
createAgent(agent: Agent, admit?: AgentAdmission): Promise<'created' | 'address_taken' | 'key_in_use' | 'too_many_keys'>;
|
|
191
|
+
agentByAddress(address: string): Promise<Agent | null>;
|
|
192
|
+
/** The agent holding this key, whether the key is active or revoked. */
|
|
193
|
+
agentByKey(key: string): Promise<Agent | null>;
|
|
194
|
+
/**
|
|
195
|
+
* Add a key, atomically, unless the agent already holds `maxActive` unrevoked keys or `MAX_STORED_KEYS`
|
|
196
|
+
* keys in all, revoked ones included.
|
|
197
|
+
*/
|
|
198
|
+
addKey(address: string, entry: AgentKey, maxActive: number, admit?: AgentAdmission): Promise<'added' | 'too_many_keys' | 'key_in_use'>;
|
|
199
|
+
/** Revoke one active key unless it is the last; compare-and-change protects racing revocations. */
|
|
200
|
+
revokeKey(address: string, key: string, at: string, admit?: AgentAdmission): Promise<'revoked' | 'last_key' | 'not_found'>;
|
|
201
|
+
/** Recovery is atomic with retirement, uniqueness and final signed-document admission. */
|
|
202
|
+
replaceKeys(address: string, entry: AgentKey, admit?: AgentAdmission): Promise<'replaced' | 'key_in_use' | 'retired' | 'too_many_keys'>;
|
|
203
|
+
keepOnlyKey(address: string, key: string, at: string): Promise<'kept' | 'not_held'>;
|
|
204
|
+
/** Only the first retirement counts, including its unpairing evidence. */
|
|
205
|
+
retireAgent(address: string, at: string, unpairing?: string): Promise<void>;
|
|
206
|
+
/** Begin only against the current admitted authority; return its existing live pending proof or the new one. */
|
|
207
|
+
beginRecovery(address: string, value: unknown, expiresAt: string | null, now: string, admit?: AgentAdmission): Promise<unknown | null>;
|
|
208
|
+
/** Activate exactly one live undefended proof after its mandatory defense completes; preserve all proof fields. */
|
|
209
|
+
activateRecovery(address: string, expectedRecovery: unknown, now: string): Promise<boolean>;
|
|
210
|
+
/** Compare pending proof and mutate keys in one change; explicit defended:false cannot complete. Rejection preserves proof. */
|
|
211
|
+
completeRecovery(address: string, entry: AgentKey, expectedRecovery: unknown, admit?: AgentAdmission): Promise<'replaced' | 'not_pending' | 'key_in_use' | 'retired' | 'too_many_keys'>;
|
|
212
|
+
/** Exact pending plus admitted actor, cancellation and optional private cooldown are one change. */
|
|
213
|
+
cancelRecovery(address: string, expectedRecovery: unknown, now: string, admit?: AgentAdmission, keyCancelledAt?: string): Promise<boolean>;
|
|
214
|
+
/**
|
|
215
|
+
* Store a message once by id, with its delivery rows (a message sent here) or its arrivals (a message
|
|
216
|
+
* taken for recipients here), all or nothing. Rows already there, by (id, domain) or (id, recipient), are
|
|
217
|
+
* left as they are; the rest are added, even when the message was stored before, so repeating a call
|
|
218
|
+
* completes it. Returns the stored record, the earlier one on a repeat. Accepted retractions retain
|
|
219
|
+
* permanent bounded suppression receipts, including after envelope deletion; their repeats never
|
|
220
|
+
* reactivate completed work. StoreCapacityError rejects new records or row grants before acceptance.
|
|
221
|
+
*
|
|
222
|
+
* A message first stored with arrivals is a received one: it stays only while an arrival waits for it,
|
|
223
|
+
* or once an arrival kept it (`arrivalDone`). Any other message was written here: it takes a position in
|
|
224
|
+
* its author's sync stream, and stays until it expires.
|
|
225
|
+
*/
|
|
226
|
+
putMessage(m: StoredMessage, rows?: {
|
|
227
|
+
deliveries?: Delivery[];
|
|
228
|
+
arrivals?: QueuedArrival[];
|
|
229
|
+
}): Promise<StoredMessage>;
|
|
230
|
+
message(id: string): Promise<StoredMessage | null>;
|
|
231
|
+
/** Remove live message state, stream positions, deliveries, arrivals and parts; preserve accepted suppression receipts. */
|
|
232
|
+
deleteMessage(id: string): Promise<void>;
|
|
233
|
+
/**
|
|
234
|
+
* The messages an agent here published (empty `to`), newest first, after the one whose id is `before` (null
|
|
235
|
+
* for the newest; an id that is not one of them matches nothing). A record's cursor is its message id.
|
|
236
|
+
*/
|
|
237
|
+
published(author: string, before: string | null, limit: number): Promise<Page>;
|
|
238
|
+
/**
|
|
239
|
+
* The messages in `address`'s inbox whose `inReplyTo` is `id`, newest first, after the one whose id is
|
|
240
|
+
* `before` (null for the newest): the relations to a published message its author may show (spec section
|
|
241
|
+
* 7.4). A record's cursor is its message id.
|
|
242
|
+
*/
|
|
243
|
+
repliesTo(address: string, id: string, before: string | null, limit: number): Promise<Page>;
|
|
244
|
+
/** Durable same-author rel:retract suppression for a live listing, scoped to its owner's accepted inbox. */
|
|
245
|
+
listingRetracted(ownerAddress: string, id: string, replyAuthor: string): Promise<boolean>;
|
|
246
|
+
/** Arrivals due at `now`, oldest first, with their messages. */
|
|
247
|
+
arrivals(now: string, limit: number): Promise<{
|
|
248
|
+
arrival: QueuedArrival;
|
|
249
|
+
message: StoredMessage;
|
|
250
|
+
generation: string;
|
|
251
|
+
}[]>;
|
|
252
|
+
/** Try an arrival again at `at`, one attempt more. Queued workers pass the snapshot generation to fence recreation. */
|
|
253
|
+
arrivalRetry(id: string, recipient: string, at: string, generation?: string): Promise<void>;
|
|
254
|
+
/**
|
|
255
|
+
* An arrival is over; `kept` says whether its recipient took or holds the message. Answers `kept` when
|
|
256
|
+
* this kept a received message for the first time, `dropped` when it removed a received message that
|
|
257
|
+
* nothing waits for and nothing kept, and null otherwise.
|
|
258
|
+
*/
|
|
259
|
+
arrivalDone(id: string, recipient: string, kept: boolean, generation?: string): Promise<'kept' | 'dropped' | null>;
|
|
260
|
+
/** Add a message to an inbox, with its inbox event waiting to be told; false when it is already there. */
|
|
261
|
+
addToInbox(address: string, id: string, at: string, generation?: string, minExpiresAt?: string): Promise<boolean>;
|
|
262
|
+
/**
|
|
263
|
+
* A page of an agent's stream after position `after`: up to `limit` messages in position order, only
|
|
264
|
+
* those that entered it at or after `from` when given; and the signals held for it, unexpired at `now`,
|
|
265
|
+
* among the positions the page covers. With `more`, the page ends at its last message. Without, it covers
|
|
266
|
+
* every position taken so far, and never one taken before a position not yet written, so a reader
|
|
267
|
+
* following `cursor` never skips a position written late.
|
|
268
|
+
*/
|
|
269
|
+
sync(address: string, after: number, limit: number, now: string, from?: string): Promise<SyncPage>;
|
|
270
|
+
/**
|
|
271
|
+
* Hold a signal for an agent at the next position of its stream until `expiresAt`, and no longer; and
|
|
272
|
+
* forget every signal expired at `now`.
|
|
273
|
+
*/
|
|
274
|
+
holdSignal(address: string, signal: Omit<HeldSignal, 'cursor'>, expiresAt: string, now: string): Promise<void>;
|
|
275
|
+
/** Inbound-only stream page for host unread views; uses the same safe watermark as sync. */
|
|
276
|
+
inbox(address: string, after: number, limit: number): Promise<SyncEntry[]>;
|
|
277
|
+
/** Whether a message is in an agent's inbox. */
|
|
278
|
+
inInbox(address: string, id: string): Promise<boolean>;
|
|
279
|
+
/** Whether an agent here has sent `recipient` a message this store still holds. */
|
|
280
|
+
wroteTo(author: string, recipient: string, self: string): Promise<boolean>;
|
|
281
|
+
/** Inbox entries whose inbox event is not yet told, in position order. */
|
|
282
|
+
inboxEvents(limit: number): Promise<SyncEntry[]>;
|
|
283
|
+
inboxEventDone(cursor: number): Promise<void>;
|
|
284
|
+
/** Add a webhook unless its agent holds `max` already: false then. Atomic, even when adds race. */
|
|
285
|
+
addWebhook(webhook: Webhook, max: number): Promise<boolean>;
|
|
286
|
+
webhook(id: string): Promise<Webhook | null>;
|
|
287
|
+
verifyWebhook(id: string): Promise<void>;
|
|
288
|
+
deleteWebhook(id: string): Promise<void>;
|
|
289
|
+
/**
|
|
290
|
+
* Something entered an agent's stream: each of its verified webhooks with no post due falls due at `at`,
|
|
291
|
+
* or when its quiet time ends, if that is later.
|
|
292
|
+
*/
|
|
293
|
+
wakeWebhooks(address: string, at: string): Promise<void>;
|
|
294
|
+
/**
|
|
295
|
+
* Claim up to `limit` webhooks due at `now`, earliest first: each is due no longer, and quiet until
|
|
296
|
+
* `quietUntil`. Atomic: two claims never take the same webhook.
|
|
297
|
+
*/
|
|
298
|
+
claimWebhooks(now: string, limit: number, quietUntil: string, lease: {
|
|
299
|
+
token: string;
|
|
300
|
+
until: string;
|
|
301
|
+
}): Promise<Webhook[]>;
|
|
302
|
+
/** A post's outcome: `failingSince` as it now stands, and after a failure, a retry due at `retryAt` unless one is. */
|
|
303
|
+
settleWebhook(id: string, failingSince: string | null, retryAt: string | null, token: string, disable?: boolean): Promise<boolean>;
|
|
304
|
+
/**
|
|
305
|
+
* Claim up to `limit` pending rows due at `now`, earliest first, except those whose `server` is in `skip`:
|
|
306
|
+
* each is leased to `lease.token` and not due again before `lease.until`. Atomic: two claims never take
|
|
307
|
+
* the same row.
|
|
308
|
+
*/
|
|
309
|
+
claimDeliveries(now: string, limit: number, skip: string[], lease: {
|
|
310
|
+
token: string;
|
|
311
|
+
until: string;
|
|
312
|
+
}): Promise<Delivery[]>;
|
|
313
|
+
/**
|
|
314
|
+
* Write an attempt's outcome to the row (id, domain): its server, status, reason, attempts and next attempt.
|
|
315
|
+
* Only while the row is pending and leased to `token`, and the lease ends; false when nothing was written.
|
|
316
|
+
*/
|
|
317
|
+
settleDelivery(row: Delivery, token: string): Promise<boolean>;
|
|
318
|
+
deliveries(id: string): Promise<Delivery[]>;
|
|
319
|
+
putPart(sha256: string, bytes: Buffer, source: PartSource, at: string): Promise<void>;
|
|
320
|
+
part(sha256: string, source: PartSource): Promise<Buffer | null>;
|
|
321
|
+
/**
|
|
322
|
+
* The sources of the bytes held for an agent (spec section 7.2), held yet or not, with the size the
|
|
323
|
+
* naming message gives: its own upload, and for a message in its inbox naming the part, the carrier's
|
|
324
|
+
* bytes, or the sources held for the author when the carrier is `self`, through any number of local forwards.
|
|
325
|
+
* Follow only inbox grants for this hash, with cycle detection; never search globally for matching bytes.
|
|
326
|
+
*/
|
|
327
|
+
heldFor(sha256: string, address: string, self: string): Promise<(PartSource & {
|
|
328
|
+
size: number;
|
|
329
|
+
})[]>;
|
|
330
|
+
/**
|
|
331
|
+
* The agents here, not retired, whose messages naming the part a reader may see: for `published`, their
|
|
332
|
+
* published messages (the caller judges each audience); for a `server`, their messages delivered to that
|
|
333
|
+
* server (status `delivered`). Each with the naming message.
|
|
334
|
+
*/
|
|
335
|
+
partNamers(sha256: string, viewer: {
|
|
336
|
+
published: true;
|
|
337
|
+
} | {
|
|
338
|
+
server: string;
|
|
339
|
+
}, self: string): Promise<{
|
|
340
|
+
author: string;
|
|
341
|
+
message: AnyMessage;
|
|
342
|
+
}[]>;
|
|
343
|
+
/**
|
|
344
|
+
* Queue a fetch unless one is queued, and return the one now queued. With `max`, a fetch not yet queued is
|
|
345
|
+
* not queued (null) while `max` fetches of its carrier are waiting (not given up); racing callers may pass
|
|
346
|
+
* it by a few.
|
|
347
|
+
*/
|
|
348
|
+
queueFetch(f: PartFetch, now?: string): Promise<PartFetch>;
|
|
349
|
+
queueFetch(f: PartFetch, now: string | undefined, max: number): Promise<PartFetch | null>;
|
|
350
|
+
/**
|
|
351
|
+
* Fetches due at `now`, taken in turn across carriers: every carrier's earliest, earliest first, then
|
|
352
|
+
* every carrier's second, and so on, so no carrier's backlog hides another's.
|
|
353
|
+
*/
|
|
354
|
+
fetches(now: string, limit: number): Promise<PartFetch[]>;
|
|
355
|
+
/**
|
|
356
|
+
* Claim a fetch read from `fetches`: move it to `until`, only if it is still due at `now` and still has the
|
|
357
|
+
* attempts and due time it was read with. Atomic: of callers racing with the same row, one wins, and a row
|
|
358
|
+
* read before another caller settled it (done, retried or deferred) is never claimed. True for the winner.
|
|
359
|
+
* Each worker supplies a unique token. Only that token can renew or settle; renewal also requires a live lease.
|
|
360
|
+
*/
|
|
361
|
+
claimFetch(f: PartFetch, now: string, lease: {
|
|
362
|
+
token: string;
|
|
363
|
+
until: string;
|
|
364
|
+
}): Promise<boolean>;
|
|
365
|
+
renewFetch(sha256: string, carrier: string, token: string, now: string, until: string): Promise<boolean>;
|
|
366
|
+
/** Try a fetch again at `at`, one attempt more. */
|
|
367
|
+
fetchRetry(sha256: string, carrier: string, at: string, token: string): Promise<boolean>;
|
|
368
|
+
/** Move a fetch to `at` without counting an attempt: it waited its turn, and was not tried. */
|
|
369
|
+
fetchDefer(sha256: string, carrier: string, at: string, token: string): Promise<boolean>;
|
|
370
|
+
fetchDone(sha256: string, carrier: string, token: string): Promise<boolean>;
|
|
371
|
+
/** Terminal failure and its ownership fence are one atomic row change; stale attempts cannot mark it. */
|
|
372
|
+
fetchGiveUp(sha256: string, carrier: string, token: string, expiresAt: string): Promise<boolean>;
|
|
373
|
+
fetchExhausted(sha256: string, carrier: string, now: string): Promise<boolean>;
|
|
374
|
+
/** Set a mark unless an unexpired one exists: true for the one caller that set it. */
|
|
375
|
+
mark(scope: string, id: string, expiresAt: string, now: string): Promise<boolean>;
|
|
376
|
+
marked(scope: string, id: string, now: string): Promise<boolean>;
|
|
377
|
+
/**
|
|
378
|
+
* Add `by` (one unless given; it may be negative, to take back what was added) to a counter, atomically,
|
|
379
|
+
* and return the new count; the first count fixes its expiry.
|
|
380
|
+
*/
|
|
381
|
+
count(scope: string, id: string, expiresAt: string, now: string, by?: number): Promise<number>;
|
|
382
|
+
/** A value, or null once it expired. */
|
|
383
|
+
value<T>(scope: string, id: string, now: string): Promise<T | null>;
|
|
384
|
+
setValue(scope: string, id: string, value: unknown, expiresAt: string | null): Promise<void>;
|
|
385
|
+
/** Remove a value only while it is still `value`, atomically: true when this removed it. */
|
|
386
|
+
releaseValue(scope: string, id: string, value: unknown): Promise<boolean>;
|
|
387
|
+
/** Move a value's expiry to `expiresAt` only while it is still `value`, atomically: true when this did. */
|
|
388
|
+
renewValue(scope: string, id: string, value: unknown, expiresAt: string): Promise<boolean>;
|
|
389
|
+
/** Set a value unless an unexpired one exists, and return the value now stored. */
|
|
390
|
+
valueOnce<T>(scope: string, id: string, value: T, expiresAt: string | null, now: string): Promise<T>;
|
|
391
|
+
/**
|
|
392
|
+
* Remove what expired at `now`: messages past `expiresAt` (with their stream positions, deliveries,
|
|
393
|
+
* arrivals and part names); fetches no message from their carrier names any more, waiting or given up; then
|
|
394
|
+
* bytes no message names any more, fetched bytes at once and uploads made before `uploadsBefore`; then
|
|
395
|
+
* expired marks, counters, values and signals. A store may take the unnamed fetches, and rows a crash left
|
|
396
|
+
* behind a removed message, a bounded slice per sweep, so each sweep's work stays bounded.
|
|
397
|
+
*/
|
|
398
|
+
sweep(now: string, uploadsBefore: string): Promise<SweepCounts>;
|
|
399
|
+
close(): Promise<void>;
|
|
400
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class StoreCapacityError extends Error {
|
|
2
|
+
code = 'store_capacity';
|
|
3
|
+
constructor() {
|
|
4
|
+
super('The server is temporarily at capacity.');
|
|
5
|
+
this.name = 'StoreCapacityError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function resolveRetractionCapacity(input = {}) {
|
|
9
|
+
const limits = { maxRecords: 4096, maxPerAuthor: 256, maxBytes: 4 * 1024 * 1024, ...input };
|
|
10
|
+
for (const n of Object.values(limits))
|
|
11
|
+
if (!Number.isSafeInteger(n) || n < 1)
|
|
12
|
+
throw new TypeError('Retraction limits are positive safe integers.');
|
|
13
|
+
if (limits.maxBytes > 8 * 1024 * 1024)
|
|
14
|
+
throw new TypeError('Retraction admission is limited to 8 MiB.');
|
|
15
|
+
return limits;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The most keys, revoked ones included, that adding a key may leave an agent with: four published
|
|
19
|
+
* documents' worth (PROTOCOL.md section 4 lists at most 32). A revoked key is kept so it never returns, which
|
|
20
|
+
* leaves history unbounded without a cap, and a Mongo agent document is limited to 16 MiB. Recovery is never
|
|
21
|
+
* refused for it: a recovery replaces every key with one, and refusing it would let a key holder block it.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_STORED_KEYS = 128;
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Context } from './context.ts';
|
|
2
|
+
import { type Reply, type Request } from './http.ts';
|
|
3
|
+
/** Who waits for news of which agent in this process: each `sync` held open. */
|
|
4
|
+
export declare class Changes {
|
|
5
|
+
#private;
|
|
6
|
+
/** Answer everyone waiting for news of `address`. */
|
|
7
|
+
tell(address: string): void;
|
|
8
|
+
/** Wait until `address` is told of, or `ms` pass; false once closed, when no one waits any more. */
|
|
9
|
+
wait(address: string, ms: number): Promise<boolean>;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
/** Something entered an agent's stream: its waiting syncs answer, and its webhooks fall due. */
|
|
13
|
+
export declare function changed(ctx: Context, address: string): Promise<void>;
|
|
14
|
+
export declare function sync(ctx: Context, req: Request): Promise<Reply>;
|
|
15
|
+
/**
|
|
16
|
+
* One entry per recipient server. Domains not yet resolved, or that resolved to no server, have `server`
|
|
17
|
+
* null; domains that resolved to the same server share its entry.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sent(ctx: Context, req: Request): Promise<Reply>;
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent reads about its own messages (CLIENT.md):
|
|
3
|
+
*
|
|
4
|
+
* GET /simon/sync?since=<token>&limit=<n>&wait=<seconds> what entered the agent's stream since the token:
|
|
5
|
+
* the messages it received and those it wrote here, from any of its
|
|
6
|
+
* clients, in the order this server took them, each with its carrier and
|
|
7
|
+
* its claims (the authoritative ones always, the others only from a
|
|
8
|
+
* carrier this server trusts, and the carrier's claims as it signed
|
|
9
|
+
* them, so any client checks them); and the signals it was told of
|
|
10
|
+
* GET /simon/sent/<id> delivery of one of its messages, per recipient server
|
|
11
|
+
*
|
|
12
|
+
* Sync shows what entered the stream within the last `syncDays` (published in the server document). A
|
|
13
|
+
* token is the position it reaches and when it was given, so one older than that, which may reach back
|
|
14
|
+
* past what sync no longer shows, is `410 token_expired`, as is one this server never gave: a gap is said,
|
|
15
|
+
* never hidden. This server keeps no history, so `marks` and `removed` are always empty.
|
|
16
|
+
*
|
|
17
|
+
* With nothing new, a sync waits up to `wait` seconds for something to enter the stream: told at once
|
|
18
|
+
* within this process, and looked for again every few seconds, for what another process wrote.
|
|
19
|
+
*/
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { invalid, unauthorized, WireError } from '@agnt-sdk/simon-protocol';
|
|
22
|
+
import { authenticateAgent, authenticateAgentKey } from "./auth.js";
|
|
23
|
+
import { iso } from "./context.js";
|
|
24
|
+
import { notFound } from "./errors.js";
|
|
25
|
+
import { shownClaims } from "./fanout.js";
|
|
26
|
+
import { json } from "./http.js";
|
|
27
|
+
const DAY_MS = 86_400_000;
|
|
28
|
+
const MAX_WAIT_S = 60;
|
|
29
|
+
/** How often a waiting sync looks again, for what other processes wrote. */
|
|
30
|
+
const RECHECK_MS = 5_000;
|
|
31
|
+
// A persisted per-origin secret keeps checkpoints valid across workers and restarts, without a
|
|
32
|
+
// database row for every poll. Authenticated encryption binds both position and account to this host.
|
|
33
|
+
async function checkpointKey(ctx) {
|
|
34
|
+
const value = await ctx.store.valueOnce('sync-secret', ctx.config.origin, crypto.randomBytes(32).toString('base64url'), null, iso(ctx.now()));
|
|
35
|
+
const key = Buffer.from(value, 'base64url');
|
|
36
|
+
if (key.length !== 32)
|
|
37
|
+
throw new Error('Invalid persisted sync secret');
|
|
38
|
+
return key;
|
|
39
|
+
}
|
|
40
|
+
function checkpoint(key, origin, address, cursor, at) {
|
|
41
|
+
const iv = crypto.randomBytes(12);
|
|
42
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
43
|
+
cipher.setAAD(Buffer.from(origin));
|
|
44
|
+
const data = Buffer.concat([cipher.update(JSON.stringify({ v: 1, address, cursor, at })), cipher.final()]);
|
|
45
|
+
return Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64url');
|
|
46
|
+
}
|
|
47
|
+
function position(key, origin, address, token, oldest, now) {
|
|
48
|
+
try {
|
|
49
|
+
if (token.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(token))
|
|
50
|
+
throw tokenExpired();
|
|
51
|
+
const data = Buffer.from(token, 'base64url');
|
|
52
|
+
if (data.length < 29 || data.toString('base64url') !== token)
|
|
53
|
+
throw tokenExpired();
|
|
54
|
+
const cipher = crypto.createDecipheriv('aes-256-gcm', key, data.subarray(0, 12));
|
|
55
|
+
cipher.setAAD(Buffer.from(origin));
|
|
56
|
+
cipher.setAuthTag(data.subarray(12, 28));
|
|
57
|
+
const value = JSON.parse(Buffer.concat([cipher.update(data.subarray(28)), cipher.final()]).toString('utf8'));
|
|
58
|
+
if (value.v !== 1 ||
|
|
59
|
+
value.address !== address ||
|
|
60
|
+
!Number.isSafeInteger(value.cursor) ||
|
|
61
|
+
value.cursor < 0 ||
|
|
62
|
+
!Number.isSafeInteger(value.at) ||
|
|
63
|
+
value.at < oldest ||
|
|
64
|
+
value.at > now)
|
|
65
|
+
throw tokenExpired();
|
|
66
|
+
return value.cursor;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw tokenExpired();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Who waits for news of which agent in this process: each `sync` held open. */
|
|
73
|
+
export class Changes {
|
|
74
|
+
#waiting = new Map();
|
|
75
|
+
#closed = false;
|
|
76
|
+
/** Answer everyone waiting for news of `address`. */
|
|
77
|
+
tell(address) {
|
|
78
|
+
for (const done of this.#waiting.get(address) ?? [])
|
|
79
|
+
done();
|
|
80
|
+
}
|
|
81
|
+
/** Wait until `address` is told of, or `ms` pass; false once closed, when no one waits any more. */
|
|
82
|
+
async wait(address, ms) {
|
|
83
|
+
if (this.#closed)
|
|
84
|
+
return false;
|
|
85
|
+
const waiting = this.#waiting.get(address) ?? new Set();
|
|
86
|
+
this.#waiting.set(address, waiting);
|
|
87
|
+
let done;
|
|
88
|
+
const told = new Promise(resolve => (done = resolve));
|
|
89
|
+
const timer = setTimeout(done, ms);
|
|
90
|
+
waiting.add(done);
|
|
91
|
+
await told;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
waiting.delete(done);
|
|
94
|
+
if (!waiting.size)
|
|
95
|
+
this.#waiting.delete(address);
|
|
96
|
+
return !this.#closed;
|
|
97
|
+
}
|
|
98
|
+
close() {
|
|
99
|
+
this.#closed = true;
|
|
100
|
+
for (const address of this.#waiting.keys())
|
|
101
|
+
this.tell(address);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Something entered an agent's stream: its waiting syncs answer, and its webhooks fall due. */
|
|
105
|
+
export async function changed(ctx, address) {
|
|
106
|
+
ctx.changes.tell(address);
|
|
107
|
+
await ctx.store.wakeWebhooks(address, iso(ctx.now()));
|
|
108
|
+
ctx.wake();
|
|
109
|
+
}
|
|
110
|
+
function whole(value, fallback, min, max, name) {
|
|
111
|
+
if (value === null)
|
|
112
|
+
return fallback;
|
|
113
|
+
const n = /^[0-9]{1,3}$/.test(value) ? Number(value) : NaN;
|
|
114
|
+
if (!(n >= min && n <= max))
|
|
115
|
+
throw invalid(`${name} is a whole number from ${min} to ${max}.`, { field: name });
|
|
116
|
+
return n;
|
|
117
|
+
}
|
|
118
|
+
const tokenExpired = () => new WireError(410, 'token_expired', 'This token reaches back past what sync still shows, or is unknown; sync anew.');
|
|
119
|
+
export async function sync(ctx, req) {
|
|
120
|
+
const { agent, key: signer } = await authenticateAgentKey(ctx, req);
|
|
121
|
+
const limit = whole(req.query.get('limit'), 50, 1, 200, 'limit');
|
|
122
|
+
const wait = whole(req.query.get('wait'), 0, 0, MAX_WAIT_S, 'wait');
|
|
123
|
+
const now = ctx.now();
|
|
124
|
+
const oldest = now.getTime() - ctx.config.syncDays * DAY_MS;
|
|
125
|
+
const key = await checkpointKey(ctx);
|
|
126
|
+
const since = req.query.get('since');
|
|
127
|
+
if (since === null && (await ctx.store.value('sync-history-gap', agent.address, iso(now)))) {
|
|
128
|
+
// Every device must learn of the upgrade gap. This does not clear account state or silently replay.
|
|
129
|
+
throw new WireError(410, 'token_expired', 'This account predates sent-message tracking; retained sent history may be incomplete. Explicitly resume with the supplied token.', { reason: 'history_migration', resumeToken: checkpoint(key, ctx.config.origin, agent.address, 0, now.getTime()) });
|
|
130
|
+
}
|
|
131
|
+
const after = since === null ? 0 : position(key, ctx.config.origin, agent.address, since, oldest, now.getTime());
|
|
132
|
+
const read = () => ctx.store.sync(agent.address, after, limit, iso(ctx.now()), iso(new Date(ctx.now().getTime() - ctx.config.syncDays * DAY_MS)));
|
|
133
|
+
let page = await read();
|
|
134
|
+
if (page.cursor < after)
|
|
135
|
+
throw tokenExpired();
|
|
136
|
+
const deadline = Date.now() + wait * 1000;
|
|
137
|
+
while (isEmpty(page) && Date.now() < deadline) {
|
|
138
|
+
if (!(await ctx.changes.wait(agent.address, Math.min(deadline - Date.now(), RECHECK_MS))))
|
|
139
|
+
break;
|
|
140
|
+
page = await read();
|
|
141
|
+
if (page.cursor < after)
|
|
142
|
+
throw tokenExpired();
|
|
143
|
+
}
|
|
144
|
+
// Authentication consumed the nonce once. Recheck its authority after waiting without replaying it.
|
|
145
|
+
const current = await ctx.store.agentByAddress(agent.address);
|
|
146
|
+
const active = current?.keys.find(k => k.key === signer.key);
|
|
147
|
+
if (!current ||
|
|
148
|
+
current.retiredAt ||
|
|
149
|
+
!active ||
|
|
150
|
+
(current.authorizedKeys && !current.authorizedKeys.includes(signer.key)) ||
|
|
151
|
+
(active.revoked !== null && active.revoked <= iso(ctx.now()))) {
|
|
152
|
+
throw unauthorized('bad_signature', 'This signing key is no longer active.', ctx.now());
|
|
153
|
+
}
|
|
154
|
+
const at = page.more
|
|
155
|
+
? Math.min(ctx.now().getTime(), ...page.messages.map(e => Date.parse(e.receivedAt)), ...page.signals.map(e => Date.parse(e.at)))
|
|
156
|
+
: ctx.now().getTime();
|
|
157
|
+
return json({
|
|
158
|
+
messages: page.messages.map(e => entry(ctx, e)),
|
|
159
|
+
marks: [],
|
|
160
|
+
removed: [],
|
|
161
|
+
signals: page.signals.map(({ type, from, about, at }) => ({ type, from, about, at })),
|
|
162
|
+
token: checkpoint(key, ctx.config.origin, agent.address, page.cursor, at),
|
|
163
|
+
more: page.more,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const isEmpty = (page) => !page.messages.length && !page.signals.length;
|
|
167
|
+
const entry = (ctx, e) => ({
|
|
168
|
+
id: e.id,
|
|
169
|
+
message: e.message,
|
|
170
|
+
carrier: e.carrier,
|
|
171
|
+
claims: shownClaims(ctx, e.carrier, e.claims),
|
|
172
|
+
...(e.signedClaims && { signedClaims: e.signedClaims }),
|
|
173
|
+
receivedAt: e.receivedAt,
|
|
174
|
+
});
|
|
175
|
+
/**
|
|
176
|
+
* One entry per recipient server. Domains not yet resolved, or that resolved to no server, have `server`
|
|
177
|
+
* null; domains that resolved to the same server share its entry.
|
|
178
|
+
*/
|
|
179
|
+
export async function sent(ctx, req) {
|
|
180
|
+
const agent = await authenticateAgent(ctx, req);
|
|
181
|
+
const record = await ctx.store.message(req.params.id);
|
|
182
|
+
if (!record || record.message.from !== agent.address || record.carrier !== ctx.config.origin) {
|
|
183
|
+
throw notFound('This agent sent no message with this id here.');
|
|
184
|
+
}
|
|
185
|
+
const servers = new Map();
|
|
186
|
+
for (const d of await ctx.store.deliveries(record.id)) {
|
|
187
|
+
const reason = d.status === 'failed' ? (d.reason ?? 'failed') : undefined;
|
|
188
|
+
const key = d.server === null ? `domain ${d.domain}` : `${d.server} ${d.status} ${reason ?? ''}`;
|
|
189
|
+
const entry = servers.get(key) ?? {
|
|
190
|
+
server: d.server,
|
|
191
|
+
recipients: [],
|
|
192
|
+
status: d.status,
|
|
193
|
+
...(reason ? { reason } : {}),
|
|
194
|
+
};
|
|
195
|
+
entry.recipients.push(...d.recipients);
|
|
196
|
+
servers.set(key, entry);
|
|
197
|
+
}
|
|
198
|
+
return json({ id: record.id, servers: [...servers.values()] });
|
|
199
|
+
}
|