@scp-dev/hub 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +41 -2
- package/dist/index.d.ts +602 -0
- package/dist/index.js +2873 -0
- package/package.json +39 -4
- package/src/federation.ts +1817 -0
- package/src/hub.ts +1602 -0
- package/src/index.ts +15 -0
|
@@ -0,0 +1,1817 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Admission,
|
|
3
|
+
ACCOUNT_HANDLE,
|
|
4
|
+
admits,
|
|
5
|
+
type Claim,
|
|
6
|
+
cleanLabels,
|
|
7
|
+
cleanText,
|
|
8
|
+
type CoreOptions,
|
|
9
|
+
effectiveSelector,
|
|
10
|
+
type EndpointKind,
|
|
11
|
+
type EndpointRow,
|
|
12
|
+
type EndpointView,
|
|
13
|
+
type Envelope,
|
|
14
|
+
findSecret,
|
|
15
|
+
GONE_PREFIX,
|
|
16
|
+
groupCapKey,
|
|
17
|
+
HUMAN_HANDLE,
|
|
18
|
+
LIMITS,
|
|
19
|
+
MESSAGE_KINDS,
|
|
20
|
+
type MessageRow,
|
|
21
|
+
newId,
|
|
22
|
+
one,
|
|
23
|
+
type PolicySubject,
|
|
24
|
+
type Presence,
|
|
25
|
+
randomHex,
|
|
26
|
+
RelayCore,
|
|
27
|
+
RelayError,
|
|
28
|
+
type ScopeCap,
|
|
29
|
+
type ScopeCaps,
|
|
30
|
+
type ScopeDirection,
|
|
31
|
+
type ScopeEntry,
|
|
32
|
+
type ScopeNode,
|
|
33
|
+
type ScopeRules,
|
|
34
|
+
type ScopeSubject,
|
|
35
|
+
type SendParams,
|
|
36
|
+
type SendResult,
|
|
37
|
+
STATUS_STATES,
|
|
38
|
+
type SqlValue,
|
|
39
|
+
type StatusState,
|
|
40
|
+
SYSTEM_HANDLE,
|
|
41
|
+
seqList,
|
|
42
|
+
unreachable,
|
|
43
|
+
validHandle,
|
|
44
|
+
validHarness,
|
|
45
|
+
validSelector,
|
|
46
|
+
} from "@scp-dev/core"
|
|
47
|
+
|
|
48
|
+
export type RelayAuth = Envelope["from"]["auth"]
|
|
49
|
+
|
|
50
|
+
/** a machine name: one relay host of an account */
|
|
51
|
+
export const MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
52
|
+
|
|
53
|
+
const REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/
|
|
54
|
+
const MESSAGE_ID = /^[a-z]{2,8}_[a-z0-9]{8,64}$/
|
|
55
|
+
const AUTHS: readonly RelayAuth[] = ["local-uid", "account", "account+bridge", "contact"]
|
|
56
|
+
const PRESENCES: readonly Presence[] = ["online", "idle", "offline"]
|
|
57
|
+
|
|
58
|
+
export interface FederatedOptions extends CoreOptions {
|
|
59
|
+
/** this host's own machine name; addresses naming it stay local */
|
|
60
|
+
machine?: string
|
|
61
|
+
/** other names of the local user, such as the account handle once known */
|
|
62
|
+
aliases?: string[]
|
|
63
|
+
/** `from.auth` of senders whose row records none (default local-uid) */
|
|
64
|
+
auth?: RelayAuth
|
|
65
|
+
/** at most this many live endpoints, mirrored and claimed together */
|
|
66
|
+
endpointCap?: number
|
|
67
|
+
/** at most this many refusal audit rows a minute for each refusal code; the next one written counts those left out */
|
|
68
|
+
refusalsPerMinute?: number
|
|
69
|
+
/** the hosted relay's cross-account tables: messages from other accounts in xinbox, in one seq space with deliveries */
|
|
70
|
+
xinbox?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface FederatedRow extends EndpointRow {
|
|
74
|
+
remote: string | null
|
|
75
|
+
auth: RelayAuth | null
|
|
76
|
+
incarnation: string | null
|
|
77
|
+
/** this machine's own agent: moves on when its caps loosen, so the incarnation it mirrors changes */
|
|
78
|
+
scope_epoch: number | null
|
|
79
|
+
/** this machine's own agent: the wire caps it mirrored last */
|
|
80
|
+
mirrored_caps: string | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** own rules as they cross machines: selector lists per direction */
|
|
84
|
+
export interface MirrorRules {
|
|
85
|
+
in?: string[]
|
|
86
|
+
out?: string[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** caps and allowances as they cross machines: `by` and `via` name an endpoint of the same machine as `ep:<handle>`, else human, policy, inherited or gone */
|
|
90
|
+
export interface MirrorCaps {
|
|
91
|
+
caps: ScopeCap[]
|
|
92
|
+
allow?: Record<string, ScopeEntry[]>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** an agent endpoint's place in its machine's tree, its rules and its caps; a field left out is unknown, or unchanged once known */
|
|
96
|
+
export interface MirrorScope {
|
|
97
|
+
/** the root's handle on the same machine (a root's own) */
|
|
98
|
+
tree?: string | null
|
|
99
|
+
/** the parent's handle on the same machine; null for a root */
|
|
100
|
+
parent_ep?: string | null
|
|
101
|
+
/** 0 for a root; null when not known */
|
|
102
|
+
depth?: number | null
|
|
103
|
+
rules?: MirrorRules | null
|
|
104
|
+
caps?: MirrorCaps | null
|
|
105
|
+
/**
|
|
106
|
+
* changes when its machine released the endpoint and another took the handle, a new spawn took it over, or its caps loosened: the caps
|
|
107
|
+
* seen before lapse. The part before the first "." names the holder: a change there also drops what a hub queued for the endpoint
|
|
108
|
+
*/
|
|
109
|
+
incarnation?: string | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** an endpoint as another machine reports it: EndpointView-like, only the address's last segment is used as the handle */
|
|
113
|
+
export interface MirrorView extends MirrorScope {
|
|
114
|
+
address: string
|
|
115
|
+
kind: EndpointKind
|
|
116
|
+
harness?: string | null
|
|
117
|
+
model?: string | null
|
|
118
|
+
cwd?: string | null
|
|
119
|
+
labels?: Record<string, string>
|
|
120
|
+
status?: { state: StatusState; note: string } | null
|
|
121
|
+
presence?: Presence
|
|
122
|
+
last_seen?: string | null
|
|
123
|
+
title?: string | null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RemoteView extends Omit<EndpointView, "depth">, MirrorScope {
|
|
127
|
+
machine: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface Placement {
|
|
131
|
+
tree: string | null
|
|
132
|
+
parent_ep: string | null
|
|
133
|
+
depth: number | null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface ParsedScope {
|
|
137
|
+
tree?: string | null
|
|
138
|
+
parent?: string | null
|
|
139
|
+
depth?: number | null
|
|
140
|
+
rules?: MirrorRules | null
|
|
141
|
+
caps?: ScopeCaps | null
|
|
142
|
+
title?: string | null
|
|
143
|
+
incarnation?: string | null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type ParsedView = {
|
|
147
|
+
handle: string
|
|
148
|
+
kind: EndpointKind
|
|
149
|
+
harness: string | null
|
|
150
|
+
model: string | null
|
|
151
|
+
cwd: string | null
|
|
152
|
+
labels: string
|
|
153
|
+
status: { state: StatusState; note: string } | null
|
|
154
|
+
presence: Presence
|
|
155
|
+
lastSeen: number | null
|
|
156
|
+
scope: ParsedScope
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const UNKNOWN: Placement = { tree: null, parent_ep: null, depth: null }
|
|
160
|
+
const REFS = new Set(["human", "policy", "inherited", "gone"])
|
|
161
|
+
const INCARNATION = /^[A-Za-z0-9._:-]{1,128}$/
|
|
162
|
+
const SCOPE_BYTES = 16 * 1024
|
|
163
|
+
|
|
164
|
+
const XACCT_MIGRATION = [
|
|
165
|
+
`CREATE TABLE IF NOT EXISTS xinbox (seq INTEGER PRIMARY KEY, id TEXT NOT NULL, from_account TEXT NOT NULL, client_id TEXT NOT NULL, endpoint TEXT NOT NULL, sender TEXT NOT NULL, message TEXT NOT NULL, thread TEXT NOT NULL, grant TEXT, expires_at INTEGER NOT NULL, at INTEGER NOT NULL, UNIQUE (from_account, client_id))`,
|
|
166
|
+
`CREATE TABLE IF NOT EXISTS xcursor (endpoint TEXT PRIMARY KEY, acked_seq INTEGER NOT NULL, at INTEGER, old_seq INTEGER, old_at INTEGER) WITHOUT ROWID`,
|
|
167
|
+
`CREATE TABLE IF NOT EXISTS xstate (key TEXT PRIMARY KEY, value TEXT) WITHOUT ROWID`,
|
|
168
|
+
`CREATE TABLE IF NOT EXISTS xcounters (contact TEXT PRIMARY KEY, day INTEGER, sent INTEGER, at INTEGER) WITHOUT ROWID`,
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* how long an xinbox row outlives its receipt, and an acked one its ack (24 to about 48 h, by the cursor's checkpoint): the reply
|
|
173
|
+
* grant, the dedupe of a retry and the recipient's rate counts read it
|
|
174
|
+
*/
|
|
175
|
+
const XINBOX_KEEP_MS = 86_400_000
|
|
176
|
+
|
|
177
|
+
/** the highest seq handed out so far: deliveries' and xinbox's, kept past purges by the acked cursors and the sweep's floor */
|
|
178
|
+
const SEQ_TOP = `MAX(COALESCE((SELECT seq FROM sqlite_sequence WHERE name = 'deliveries'), 0), COALESCE((SELECT MAX(seq) FROM xinbox), 0),
|
|
179
|
+
COALESCE((SELECT MAX(acked_seq) FROM xcursor), 0), COALESCE((SELECT CAST(value AS INTEGER) FROM xstate WHERE key = 'seq_floor'), 0))`
|
|
180
|
+
|
|
181
|
+
/** a row of xinbox: a message from another account, its envelope as JSON */
|
|
182
|
+
export interface XInboxRow {
|
|
183
|
+
seq: number
|
|
184
|
+
id: string
|
|
185
|
+
from_account: string
|
|
186
|
+
client_id: string
|
|
187
|
+
endpoint: string
|
|
188
|
+
/** the sender's address in its account */
|
|
189
|
+
sender: string
|
|
190
|
+
message: string
|
|
191
|
+
thread: string
|
|
192
|
+
grant: string | null
|
|
193
|
+
expires_at: number
|
|
194
|
+
/** when it was received */
|
|
195
|
+
at: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** a mirrored endpoint of this account as the cross-account checks see it */
|
|
199
|
+
export interface XEndpoint {
|
|
200
|
+
id: string
|
|
201
|
+
/** `<machine>/<handle>`, handle empty for a machine's human */
|
|
202
|
+
remote: string
|
|
203
|
+
machine: string
|
|
204
|
+
handle: string
|
|
205
|
+
kind: EndpointKind
|
|
206
|
+
depth: number | null
|
|
207
|
+
harness: string | null
|
|
208
|
+
presence: Presence
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface MirrorChange {
|
|
212
|
+
view: RemoteView
|
|
213
|
+
added: boolean
|
|
214
|
+
presence: boolean
|
|
215
|
+
status: boolean
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface Delivery {
|
|
219
|
+
seq: number
|
|
220
|
+
/** `<machine>/<handle>` of the recipient, or null for a row of no machine */
|
|
221
|
+
endpoint: string | null
|
|
222
|
+
/** the recipient's endpoint id */
|
|
223
|
+
recipient: string
|
|
224
|
+
/** whether the recipient is a row its machine's bridge mirrors, not one a direct client claimed */
|
|
225
|
+
mirrored: boolean
|
|
226
|
+
message: Envelope
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const machinePart = (remote: string): string => remote.slice(0, remote.indexOf("/"))
|
|
230
|
+
/** a stand-in for another account's sender, `@<account>/...`: never a machine of this account */
|
|
231
|
+
const isForeign = (remote: string): boolean => remote.startsWith("@")
|
|
232
|
+
const foreignOf = (remote: string): string => remote.slice(1, remote.indexOf("/"))
|
|
233
|
+
|
|
234
|
+
/** an endpoint of another account as the scope check sees it: it matches only `acct:` selectors */
|
|
235
|
+
export function foreignNode(peer: { account: string; machine: string; handle: string }): ScopeNode {
|
|
236
|
+
const address = `${peer.account}/${peer.machine}/${peer.handle || "me"}`
|
|
237
|
+
return {
|
|
238
|
+
id: `@${address}`,
|
|
239
|
+
address,
|
|
240
|
+
subject: { handle: peer.handle, harness: null, kind: peer.handle === "" ? "human" : "agent", labels: {}, account: peer.account },
|
|
241
|
+
rules: {},
|
|
242
|
+
caps: { caps: [], allow: {} },
|
|
243
|
+
groups: [],
|
|
244
|
+
policy: [],
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const handlePart = (remote: string): string => remote.slice(remote.indexOf("/") + 1)
|
|
248
|
+
|
|
249
|
+
/** RelayCore with endpoints of other machines: qualified addresses, mirrored and proxy rows, injection and forwarding records */
|
|
250
|
+
export class FederatedCore extends RelayCore {
|
|
251
|
+
readonly machine: string | null
|
|
252
|
+
private aliases: Set<string>
|
|
253
|
+
private scope: string | null = null
|
|
254
|
+
private readonly defaultAuth: RelayAuth
|
|
255
|
+
private readonly endpointCap: number | null
|
|
256
|
+
private readonly refusalsPerMinute: number | null
|
|
257
|
+
private readonly refusals = new Map<string, { minute: number; written: number; suppressed: number }>()
|
|
258
|
+
private readonly xinbox: boolean
|
|
259
|
+
private xfrom: Set<string> | null = null
|
|
260
|
+
|
|
261
|
+
constructor(sql: ConstructorParameters<typeof RelayCore>[0], options: FederatedOptions) {
|
|
262
|
+
super(sql, options)
|
|
263
|
+
this.machine = options.machine ?? null
|
|
264
|
+
this.aliases = new Set(options.aliases ?? [])
|
|
265
|
+
this.defaultAuth = options.auth ?? "local-uid"
|
|
266
|
+
this.endpointCap = options.endpointCap ?? null
|
|
267
|
+
this.refusalsPerMinute = options.refusalsPerMinute ?? null
|
|
268
|
+
this.xinbox = options.xinbox === true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
override migrate(): void {
|
|
272
|
+
super.migrate()
|
|
273
|
+
const columns = this.sql.all<{ name: string }>(`PRAGMA table_info(endpoints)`).map((c) => c.name)
|
|
274
|
+
if (!columns.includes("remote")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN remote TEXT`)
|
|
275
|
+
if (!columns.includes("auth")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN auth TEXT`)
|
|
276
|
+
if (!columns.includes("incarnation")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN incarnation TEXT`)
|
|
277
|
+
if (!columns.includes("scope_epoch")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN scope_epoch INTEGER`)
|
|
278
|
+
if (!columns.includes("mirrored_caps")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_caps TEXT`)
|
|
279
|
+
this.sql.all(`CREATE UNIQUE INDEX IF NOT EXISTS endpoints_remote ON endpoints (remote) WHERE remote IS NOT NULL`)
|
|
280
|
+
if (this.xinbox) for (const statement of XACCT_MIGRATION) this.sql.all(statement)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** with xinbox, a delivery takes the next seq past xinbox's too, so a bridge's replay and ack cursor stay one space */
|
|
284
|
+
protected override insertDelivery(messageId: string, endpoint: string, expiresAt: number): number {
|
|
285
|
+
if (!this.xinbox) return super.insertDelivery(messageId, endpoint, expiresAt)
|
|
286
|
+
return one<{ seq: number }>(
|
|
287
|
+
this.sql,
|
|
288
|
+
`INSERT INTO deliveries (seq, message_id, endpoint, expires_at) VALUES (1 + ${SEQ_TOP}, ?, ?, ?) RETURNING seq`,
|
|
289
|
+
messageId,
|
|
290
|
+
endpoint,
|
|
291
|
+
expiresAt,
|
|
292
|
+
)!.seq
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
setAliases(aliases: string[]): void {
|
|
296
|
+
this.aliases = new Set(aliases)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** runs `fn` with unqualified handles resolving on `machine`: a direct client's own namespace on the hub */
|
|
300
|
+
within<T>(machine: string | null, fn: () => T): T {
|
|
301
|
+
const previous = this.scope
|
|
302
|
+
this.scope = machine
|
|
303
|
+
try {
|
|
304
|
+
return fn()
|
|
305
|
+
} finally {
|
|
306
|
+
this.scope = previous
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// addresses
|
|
311
|
+
|
|
312
|
+
/** the hub-side address (`<machine>/<handle>`) an address names, another account's as it is, or null when it is on this machine */
|
|
313
|
+
route(address: unknown): string | null {
|
|
314
|
+
if (this.foreignAccount(address)) return String(address).trim()
|
|
315
|
+
const { machine, handle } = this.locate(address)
|
|
316
|
+
return machine === null ? null : `${machine}/${handle}`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** whether an address names an endpoint on another machine or of another account; malformed addresses are not remote */
|
|
320
|
+
isRemote(address: unknown): boolean {
|
|
321
|
+
try {
|
|
322
|
+
return this.route(address) !== null
|
|
323
|
+
} catch {
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** the local endpoint id a hub delivery for `<machine>/<handle>` is for */
|
|
329
|
+
localTarget(target: unknown): string {
|
|
330
|
+
if (typeof target !== "string" || !REMOTE.test(target) || machinePart(target) !== this.machine)
|
|
331
|
+
throw new RelayError("not_found", `no endpoint ${String(target)} on this machine`)
|
|
332
|
+
const handle = handlePart(target)
|
|
333
|
+
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : undefined
|
|
334
|
+
if (!row) throw new RelayError("not_found", `no endpoint ${target} on this machine`)
|
|
335
|
+
return row.id
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** the live row of `<machine>/<handle>`, for a bridge acting on its own machine's endpoints */
|
|
339
|
+
remoteEndpoint(machine: string, handle: unknown): string {
|
|
340
|
+
if (typeof handle !== "string" || handle.includes("/")) throw new RelayError("invalid_params", "from is a handle on the bridge's own machine")
|
|
341
|
+
const row = this.byRemote(`${machine}/${handle}`)
|
|
342
|
+
if (!row) throw new RelayError("not_found", `no endpoint ${machine}/${handle}`)
|
|
343
|
+
return row.id
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** the machine an endpoint row belongs to, or null for a row of this host */
|
|
347
|
+
machineOf(endpoint: string): string | null {
|
|
348
|
+
const row = this.byId(endpoint) as FederatedRow | undefined
|
|
349
|
+
return row?.remote ? machinePart(row.remote) : null
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** `<account>/<machine>/<handle>` of another account (handle empty for its machine's human, `me`), or null for any other address */
|
|
353
|
+
foreignAccount(address: unknown): { account: string; machine: string; handle: string } | null {
|
|
354
|
+
if (typeof address !== "string") return null
|
|
355
|
+
const parts = address.trim().split("/")
|
|
356
|
+
if (parts.length !== 3) return null
|
|
357
|
+
const [account, machine, handle] = parts as [string, string, string]
|
|
358
|
+
if (this.isLocalUser(account) || !ACCOUNT_HANDLE.test(account) || !MACHINE_NAME.test(machine)) return null
|
|
359
|
+
if (handle !== "me" && handle !== "" && !validHandle(handle)) return null
|
|
360
|
+
return { account, machine, handle: handle === "me" ? "" : handle }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private isLocalUser(part: string): boolean {
|
|
364
|
+
return part === "me" || part === this.user || this.aliases.has(part)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** whether an endpoint of another account `part` ever reached this relay, so `<part>/me` is that account's human */
|
|
368
|
+
private knownAccount(part: string): boolean {
|
|
369
|
+
return (
|
|
370
|
+
!this.isLocalUser(part) &&
|
|
371
|
+
ACCOUNT_HANDLE.test(part) &&
|
|
372
|
+
one(this.sql, `SELECT 1 FROM endpoints WHERE substr(remote, 1, ?) = ? LIMIT 1`, part.length + 2, `@${part}/`) !== undefined
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private locate(address: unknown): { machine: string | null; handle: string } {
|
|
377
|
+
if (typeof address !== "string" || !address.trim()) throw new RelayError("invalid_params", "an address is machine/handle, user/handle, or a handle")
|
|
378
|
+
const text = address.trim()
|
|
379
|
+
const parts = text.split("/")
|
|
380
|
+
let machine: string | null
|
|
381
|
+
let handle: string
|
|
382
|
+
if (parts.length === 1) [machine, handle] = [null, parts[0]!]
|
|
383
|
+
else if (parts.length === 2 && parts[1] === "me" && this.knownAccount(parts[0]!))
|
|
384
|
+
throw new RelayError(
|
|
385
|
+
"invalid_params",
|
|
386
|
+
`${text} is account ${parts[0]}'s human, which takes no messages yet; message one of its endpoints (${parts[0]}/<machine>/<handle>)`,
|
|
387
|
+
)
|
|
388
|
+
else if (parts.length === 2) [machine, handle] = [this.isLocalUser(parts[0]!) ? null : parts[0]!, parts[1]!]
|
|
389
|
+
else if (parts.length === 3 && this.isLocalUser(parts[0]!)) [machine, handle] = [parts[1]!, parts[2]!]
|
|
390
|
+
else throw unreachable(text)
|
|
391
|
+
if (machine !== null && handle === "me") handle = ""
|
|
392
|
+
if (machine === null && this.scope !== null && handle !== "") machine = this.scope
|
|
393
|
+
if (machine !== null && machine === this.machine) machine = null
|
|
394
|
+
return { machine, handle }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
protected override resolve(address: unknown): EndpointRow {
|
|
398
|
+
const { machine, handle } = this.locate(address)
|
|
399
|
+
const text = String(address).trim()
|
|
400
|
+
if (machine === null) {
|
|
401
|
+
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : undefined
|
|
402
|
+
if (!row) throw unreachable(text)
|
|
403
|
+
return row
|
|
404
|
+
}
|
|
405
|
+
const row = this.byRemote(`${machine}/${handle}`)
|
|
406
|
+
if (!row) throw unreachable(text)
|
|
407
|
+
return row
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
protected override addressUsers(): string[] {
|
|
411
|
+
return [this.user, "me", ...this.aliases]
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
protected override localRow(row: EndpointRow): boolean {
|
|
415
|
+
return !(row as FederatedRow).remote
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
protected override address(row: EndpointRow): string {
|
|
419
|
+
const remote = (row as FederatedRow).remote
|
|
420
|
+
if (remote && isForeign(remote)) return remote.slice(1)
|
|
421
|
+
return remote ? `${this.user}/${remote}` : super.address(row)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
protected override subject(row: EndpointRow): PolicySubject {
|
|
425
|
+
const remote = (row as FederatedRow).remote
|
|
426
|
+
if (!remote) return super.subject(row)
|
|
427
|
+
return {
|
|
428
|
+
handle: "",
|
|
429
|
+
harness: row.harness,
|
|
430
|
+
kind: row.kind,
|
|
431
|
+
labels: JSON.parse(row.labels) as Record<string, string>,
|
|
432
|
+
...(isForeign(remote) ? { account: foreignOf(remote) } : {}),
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** a stand-in for another account's sender is no endpoint of this relay: list, peers and snapshots leave it out */
|
|
437
|
+
protected override listable(row: EndpointRow): boolean {
|
|
438
|
+
const remote = (row as FederatedRow).remote
|
|
439
|
+
return !remote || !isForeign(remote)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** an address of another account (a sender's stand-in, or a target with none) is checked as that account's endpoint */
|
|
443
|
+
protected override policySubject(address: string): PolicySubject {
|
|
444
|
+
const proxy = typeof address === "string" ? this.byRemote(`@${address.trim()}`) : undefined
|
|
445
|
+
if (proxy) return this.subject(proxy)
|
|
446
|
+
const foreign = this.foreignAccount(address)
|
|
447
|
+
if (foreign) return foreignNode(foreign).subject
|
|
448
|
+
return super.policySubject(address)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* another machine's row sits in no local tree: a fleet of its own on its machine, so a scoped local endpoint reaches it only by a leaving
|
|
453
|
+
* selector; its mirrored ancestors are kept so a cap widened by one of them is clipped as on its machine
|
|
454
|
+
*/
|
|
455
|
+
protected override scopeSubject(row: EndpointRow): ScopeSubject {
|
|
456
|
+
const remote = (row as FederatedRow).remote
|
|
457
|
+
if (!remote) return super.scopeSubject(row)
|
|
458
|
+
const ancestors: string[] = []
|
|
459
|
+
for (let id = row.parent_ep; id && id !== row.id && !ancestors.includes(id) && ancestors.length <= LIMITS.depthMax; id = this.byId(id)?.parent_ep ?? null)
|
|
460
|
+
ancestors.push(id)
|
|
461
|
+
return { id: row.id, tree: row.tree ?? row.id, ancestors, groups: [], rootHandle: "", machine: machinePart(remote), local: false }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** another machine's row has the rules and caps its machine mirrored; only the hub's own human (a full login on the hub) is the account's human */
|
|
465
|
+
protected override scopeNode(row: EndpointRow): ScopeNode {
|
|
466
|
+
const remote = (row as FederatedRow).remote
|
|
467
|
+
if (!remote) return super.scopeNode(row)
|
|
468
|
+
if (isForeign(remote))
|
|
469
|
+
return { id: row.id, address: this.address(row), subject: this.subject(row), rules: {}, caps: { caps: [], allow: {} }, groups: [], policy: [] }
|
|
470
|
+
const subject = row.kind === "system" ? this.subject(row) : { ...this.subject(row), scope: this.scopeSubject(row) }
|
|
471
|
+
const human = row.kind === "human" && handlePart(remote) === "" && this.isLocalUser(machinePart(remote))
|
|
472
|
+
const caps = row.caps ? (JSON.parse(row.caps) as ScopeCaps) : { caps: [], allow: {} }
|
|
473
|
+
return {
|
|
474
|
+
id: row.id,
|
|
475
|
+
address: this.address(row),
|
|
476
|
+
subject,
|
|
477
|
+
human,
|
|
478
|
+
rules: row.rules ? (JSON.parse(row.rules) as ScopeRules) : {},
|
|
479
|
+
caps: { caps: caps.caps, allow: caps.allow ?? {} },
|
|
480
|
+
groups: [],
|
|
481
|
+
policy: [],
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* throws not_found, as send() does, when the scope refuses a local endpoint's message to an address on another machine, a reply
|
|
487
|
+
* (`replyTo`, its parent) to a message it got from that address passing as a solicited one; to another account, the cross-account rule on
|
|
488
|
+
* the sender's out lists, unless it replies to a message it got from exactly that address
|
|
489
|
+
*/
|
|
490
|
+
scopeCheck(from: string, to: unknown, replyTo?: string): void {
|
|
491
|
+
const foreign = this.foreignAccount(to)
|
|
492
|
+
if (!foreign) {
|
|
493
|
+
const parent = replyTo === undefined ? undefined : one<MessageRow>(this.sql, `SELECT * FROM messages WHERE id = ?`, String(replyTo))
|
|
494
|
+
this.checkScope(this.mustEndpoint(from), this.resolve(to), String(to).trim(), parent)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const sender = this.mustEndpoint(from)
|
|
498
|
+
const text = String(to).trim()
|
|
499
|
+
if (replyTo !== undefined) {
|
|
500
|
+
const parent = one<{ from_ep: string }>(
|
|
501
|
+
this.sql,
|
|
502
|
+
`SELECT m.from_ep AS from_ep FROM messages m JOIN deliveries d ON d.message_id = m.id WHERE m.id = ? AND d.endpoint = ?`,
|
|
503
|
+
replyTo,
|
|
504
|
+
from,
|
|
505
|
+
)
|
|
506
|
+
const origin = parent ? this.byId(parent.from_ep) : undefined
|
|
507
|
+
if (origin && this.address(origin) === text) return
|
|
508
|
+
}
|
|
509
|
+
const admission = this.crossAdmission(from, "out", foreignNode(foreign))
|
|
510
|
+
if (admission.ok) return
|
|
511
|
+
this.audit("scope_refused", from, null, { from: this.address(sender), to: text, dir: "out", reason: admission.why })
|
|
512
|
+
throw new RelayError("not_found", `no endpoint ${text}`)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** whether direction `dir` of `endpoint` admits an endpoint of another account: only an `acct:` selector in each list, or no list at all */
|
|
516
|
+
crossAdmission(endpoint: string, dir: ScopeDirection, peer: ScopeNode | { account: string; machine: string; handle: string }): Admission {
|
|
517
|
+
const world = this.scopeWorld()
|
|
518
|
+
const node = world.node(endpoint)
|
|
519
|
+
if (!node) return { ok: false, why: "the endpoint is gone" }
|
|
520
|
+
return admits(world, node, dir, "id" in peer ? peer : foreignNode(peer))
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
protected override envelope(row: MessageRow, seq?: number, recipient?: string): Envelope {
|
|
524
|
+
const envelope = super.envelope(row, seq, recipient)
|
|
525
|
+
const sender = this.byId(row.from_ep) as FederatedRow | undefined
|
|
526
|
+
if (sender?.remote && isForeign(sender.remote)) {
|
|
527
|
+
const account = foreignOf(sender.remote)
|
|
528
|
+
const parts = sender.remote.slice(1).split("/")
|
|
529
|
+
const { rel: _, ...from } = envelope.from
|
|
530
|
+
envelope.from = {
|
|
531
|
+
...from,
|
|
532
|
+
user: account,
|
|
533
|
+
auth: "contact",
|
|
534
|
+
account,
|
|
535
|
+
rel: `contact:${account}`,
|
|
536
|
+
...(parts.length === 3 ? { machine: parts[1]! } : {}),
|
|
537
|
+
...(sender.kind === "human" ? { human: true as const } : {}),
|
|
538
|
+
}
|
|
539
|
+
return envelope
|
|
540
|
+
}
|
|
541
|
+
const machine = sender?.remote ? machinePart(sender.remote) : null
|
|
542
|
+
const other = machine !== null && !this.isLocalUser(machine)
|
|
543
|
+
const human = other && sender?.kind === "human" && row.sender_tty === 1
|
|
544
|
+
envelope.from = { ...envelope.from, auth: sender?.auth ?? this.defaultAuth, ...(other ? { machine } : {}), ...(human ? { human: true as const } : {}) }
|
|
545
|
+
return envelope
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// mirrored and claimed rows
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* upserts a machine's endpoints from its views, never over a direct client's claimed row; `full` marks its mirrored rows missing from the
|
|
552
|
+
* list offline. An agent's place is worked out here from its mirrored parent, and a cap seen once stays until the endpoint is released
|
|
553
|
+
* (a new `incarnation`). A row is written only when something in it changed. `seen` gathers the remotes of a full mirror sent in several
|
|
554
|
+
* frames: each frame adds its own, and the closing `full` one counts every remote in it as listed.
|
|
555
|
+
*/
|
|
556
|
+
mirror(machine: string, views: unknown, options: { full?: boolean; auth?: RelayAuth; seen?: Set<string> } = {}): MirrorChange[] {
|
|
557
|
+
this.checkMachine(machine)
|
|
558
|
+
if (!Array.isArray(views)) throw new RelayError("invalid_params", "endpoints must be a list")
|
|
559
|
+
if (views.length > (this.endpointCap ?? 4096)) throw new RelayError("too_large", `at most ${this.endpointCap ?? 4096} endpoints`)
|
|
560
|
+
const parsed = [...new Map(views.map((view) => this.parseView(view)).map((view) => [view.handle, view])).values()]
|
|
561
|
+
const auth = options.auth ?? null
|
|
562
|
+
return this.tx(() => {
|
|
563
|
+
const now = this.now()
|
|
564
|
+
const changes: MirrorChange[] = []
|
|
565
|
+
const seen = options.seen ?? new Set<string>()
|
|
566
|
+
const batch: { view: ParsedView; id: string; before: FederatedRow | undefined }[] = []
|
|
567
|
+
for (const view of parsed) {
|
|
568
|
+
const remote = `${machine}/${view.handle}`
|
|
569
|
+
seen.add(remote)
|
|
570
|
+
const before = this.byRemote(remote)
|
|
571
|
+
if (before && before.token_hash !== null) continue
|
|
572
|
+
if (!before) this.checkCap()
|
|
573
|
+
batch.push({ view, id: before?.id ?? this.insertRemote(remote, view.kind, view.harness, auth, now), before })
|
|
574
|
+
}
|
|
575
|
+
const place = this.placer(machine, batch)
|
|
576
|
+
for (const { view, id, before } of batch) {
|
|
577
|
+
const row = before ?? (this.byId(id) as FederatedRow)
|
|
578
|
+
if (before && view.kind === "agent") this.dropEarlierHolder(before, view.scope.incarnation)
|
|
579
|
+
const live = view.presence !== "offline"
|
|
580
|
+
const next: Partial<FederatedRow> = {
|
|
581
|
+
kind: view.kind,
|
|
582
|
+
harness: view.harness,
|
|
583
|
+
model: view.model,
|
|
584
|
+
cwd: view.cwd,
|
|
585
|
+
labels: view.labels,
|
|
586
|
+
status_state: view.status?.state ?? null,
|
|
587
|
+
status_note: view.status?.note ?? null,
|
|
588
|
+
token_hash: null,
|
|
589
|
+
connections: live ? 1 : 0,
|
|
590
|
+
last_seen: view.lastSeen ?? (live ? now : (before?.last_seen ?? null)),
|
|
591
|
+
auth: auth ?? row.auth,
|
|
592
|
+
...(view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}),
|
|
593
|
+
}
|
|
594
|
+
const columns = Object.entries(next).filter(([column, value]) => row[column as keyof FederatedRow] !== value)
|
|
595
|
+
if (!columns.length) continue
|
|
596
|
+
this.sql.all(
|
|
597
|
+
`UPDATE endpoints SET ${columns.map(([column]) => `${column} = ?`).join(", ")} WHERE id = ?`,
|
|
598
|
+
...columns.map(([, value]) => value as SqlValue),
|
|
599
|
+
id,
|
|
600
|
+
)
|
|
601
|
+
const change = this.diff(before, this.byId(id)!)
|
|
602
|
+
if (change) changes.push(change)
|
|
603
|
+
}
|
|
604
|
+
if (options.full) {
|
|
605
|
+
const missing = this.sql.all<FederatedRow>(
|
|
606
|
+
`SELECT * FROM endpoints WHERE substr(remote, 1, ?) = ? AND token_hash IS NULL AND connections > 0`,
|
|
607
|
+
machine.length + 1,
|
|
608
|
+
`${machine}/`,
|
|
609
|
+
)
|
|
610
|
+
for (const row of missing.filter((r) => !seen.has(r.remote!))) {
|
|
611
|
+
this.sql.all(`UPDATE endpoints SET connections = 0, last_seen = ? WHERE id = ?`, now, row.id)
|
|
612
|
+
changes.push({ view: this.remoteView(this.byId(row.id)!), added: false, presence: true, status: false })
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return changes
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* a local agent's place, rules and caps as its machine's bridge mirrors them (MirrorScope), with its title; nothing for another row. Caps
|
|
621
|
+
* looser than the ones mirrored last move the endpoint's scope epoch and so its incarnation: a hub keeps the caps it has seen until that changes
|
|
622
|
+
*/
|
|
623
|
+
mirrorScope(endpoint: string): MirrorScope & { title?: string | null } {
|
|
624
|
+
const row = this.mustEndpoint(endpoint)
|
|
625
|
+
if (row.kind !== "agent" || !this.localRow(row)) return {}
|
|
626
|
+
const node = this.scopeNode(row)
|
|
627
|
+
const root = row.tree ? this.byId(row.tree) : row
|
|
628
|
+
const parent = row.parent_ep ? this.byId(row.parent_ep) : undefined
|
|
629
|
+
const caps: ScopeCap[] = [...node.caps.caps]
|
|
630
|
+
for (const group of node.groups) {
|
|
631
|
+
for (const dir of ["in", "out"] as const) {
|
|
632
|
+
const rules = group[dir]
|
|
633
|
+
if (!rules) continue
|
|
634
|
+
const list = { source: "", by: group.owner, group: group.name, entries: [] }
|
|
635
|
+
const sels = [...rules, ...(node.caps.allow[groupCapKey(group.name, dir)] ?? [])].map((entry) => ({ ...entry, sel: effectiveSelector(list, entry) }))
|
|
636
|
+
caps.push({ id: groupCapKey(group.name, dir), dir, by: group.owner, sels })
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (const cap of node.policy) caps.push({ id: `policy:${cap.match}:${cap.dir}`, dir: cap.dir, by: "policy", sels: cap.sels })
|
|
640
|
+
const allow = Object.fromEntries(Object.entries(node.caps.allow).filter(([key]) => key.startsWith("inherited:")))
|
|
641
|
+
const rules: MirrorRules = { ...(node.rules.in ? { in: node.rules.in } : {}), ...(node.rules.out ? { out: node.rules.out } : {}) }
|
|
642
|
+
const wire = caps.length || Object.keys(allow).length ? this.wireCaps({ caps, allow }) : null
|
|
643
|
+
const epoch = this.scopeEpoch(row as FederatedRow, wire)
|
|
644
|
+
return {
|
|
645
|
+
tree: root?.handle ?? null,
|
|
646
|
+
parent_ep: parent?.handle ?? null,
|
|
647
|
+
depth: row.depth ?? 0,
|
|
648
|
+
rules: Object.keys(rules).length ? rules : null,
|
|
649
|
+
caps: wire,
|
|
650
|
+
title: row.title ?? null,
|
|
651
|
+
incarnation: `${this.hash(row.spawned_at ? `${row.id}@${row.spawned_at}` : row.id).slice(0, 24)}.${epoch}`,
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private scopeEpoch(row: FederatedRow, caps: MirrorCaps | null): number {
|
|
656
|
+
const epoch = row.scope_epoch ?? 0
|
|
657
|
+
const text = caps ? JSON.stringify(caps) : null
|
|
658
|
+
if (text === row.mirrored_caps) return epoch
|
|
659
|
+
const seen = row.mirrored_caps ? (JSON.parse(row.mirrored_caps) as MirrorCaps) : null
|
|
660
|
+
const next = seen && loosens(seen, caps ?? { caps: [] }) ? epoch + 1 : epoch
|
|
661
|
+
this.sql.all(`UPDATE endpoints SET scope_epoch = ?, mirrored_caps = ? WHERE id = ?`, next || null, text, row.id)
|
|
662
|
+
return next
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* a direct client's claim of `<machine>/<handle>`, with claim()'s takeover and suffix rules, and its `exact`, `scope`, `allow`, `in` and
|
|
667
|
+
* `out`: a new row takes the claim's own rules, a taken-over one keeps its own. An exact claim is never suffixed: it joins a live claimed
|
|
668
|
+
* row of the same harness no process or session holds, with no new token, and is refused as claim() refuses one otherwise
|
|
669
|
+
*/
|
|
670
|
+
claimOn(machine: string, claim: Claim, auth: RelayAuth = "account"): { endpoint: EndpointView; token?: string } {
|
|
671
|
+
this.checkMachine(machine)
|
|
672
|
+
const harness = cleanText(claim.harness, "harness", 32) ?? "cli"
|
|
673
|
+
if (!validHarness(harness)) throw new RelayError("invalid_params", `harness ${JSON.stringify(harness)} is not a valid name`)
|
|
674
|
+
const wanted = cleanText(claim.handle, "handle", 63) ?? harness
|
|
675
|
+
if (!validHandle(wanted)) throw new RelayError("invalid_params", `handle ${JSON.stringify(wanted)} is not valid: lowercase letters, digits, . _ -`)
|
|
676
|
+
const fields = {
|
|
677
|
+
model: cleanText(claim.model, "model", 128),
|
|
678
|
+
cwd: cleanText(claim.cwd, "cwd", 1024),
|
|
679
|
+
parent: cleanText(claim.parent, "parent", 128),
|
|
680
|
+
labels: JSON.stringify(cleanLabels(claim.labels)),
|
|
681
|
+
}
|
|
682
|
+
const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels].map((v) => (v ? findSecret(v) : null)).find(Boolean)
|
|
683
|
+
if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`)
|
|
684
|
+
const rules = this.claimRules(claim)
|
|
685
|
+
const exact = claim.exact === true
|
|
686
|
+
return this.tx(() => {
|
|
687
|
+
const now = this.now()
|
|
688
|
+
let row: FederatedRow | undefined
|
|
689
|
+
let created = false
|
|
690
|
+
let joined = false
|
|
691
|
+
for (let n = 1; n < (exact ? 2 : 1000) && !row; n++) {
|
|
692
|
+
const handle = n === 1 ? wanted : `${wanted.slice(0, 58)}-${n}`
|
|
693
|
+
const held = this.byRemote(`${machine}/${handle}`)
|
|
694
|
+
if (!held) {
|
|
695
|
+
this.checkCap()
|
|
696
|
+
row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now)) as FederatedRow
|
|
697
|
+
created = true
|
|
698
|
+
} else if (held.kind === "agent" && held.connections === 0 && held.harness === harness) {
|
|
699
|
+
row = held
|
|
700
|
+
} else if (
|
|
701
|
+
exact &&
|
|
702
|
+
held.kind === "agent" &&
|
|
703
|
+
held.token_hash !== null &&
|
|
704
|
+
held.process === null &&
|
|
705
|
+
held.session === null &&
|
|
706
|
+
held.parent_ep === null &&
|
|
707
|
+
held.harness === harness
|
|
708
|
+
) {
|
|
709
|
+
row = held
|
|
710
|
+
joined = true
|
|
711
|
+
} else if (held.kind === "agent" && held.connections === 0 && (held.last_seen ?? held.created_at) < now - LIMITS.handleBoundS * 1000) {
|
|
712
|
+
this.retire(held.id)
|
|
713
|
+
n--
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (!row) {
|
|
717
|
+
if (exact) throw this.exactRefusal(this.byRemote(`${machine}/${wanted}`)!, wanted, harness, now)
|
|
718
|
+
throw new RelayError("invalid_params", `no free handle for ${wanted} on ${machine}`)
|
|
719
|
+
}
|
|
720
|
+
if (created) {
|
|
721
|
+
const own = this.boundRules(rules)
|
|
722
|
+
if (Object.keys(own).length) this.sql.all(`UPDATE endpoints SET rules = ? WHERE id = ?`, JSON.stringify(own), row.id)
|
|
723
|
+
for (const dir of ["in", "out"] as const) this.needGroups(this.byId(row.id)!, rules[dir])
|
|
724
|
+
}
|
|
725
|
+
const token = joined ? undefined : `kre_${randomHex(32)}`
|
|
726
|
+
this.sql.all(
|
|
727
|
+
`UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
|
|
728
|
+
harness,
|
|
729
|
+
fields.model,
|
|
730
|
+
fields.cwd,
|
|
731
|
+
fields.parent,
|
|
732
|
+
fields.labels,
|
|
733
|
+
...(token ? [this.hash(token)] : []),
|
|
734
|
+
auth,
|
|
735
|
+
now,
|
|
736
|
+
row.id,
|
|
737
|
+
)
|
|
738
|
+
this.audit("claim", row.id, null, { handle: row.remote, harness, ...(joined ? { joined: true } : {}) })
|
|
739
|
+
return { endpoint: this.view(this.byId(row.id)!), ...(token ? { token } : {}) }
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* resume() for a direct client of `machine`, its labels replaced by what `relabel` makes of them, held to the label limits: a token of
|
|
745
|
+
* another machine's endpoint is refused
|
|
746
|
+
*/
|
|
747
|
+
resumeOn(machine: string, token: string, relabel?: (labels: Record<string, string>) => unknown): EndpointView {
|
|
748
|
+
const row = typeof token === "string" ? one<FederatedRow>(this.sql, `SELECT * FROM endpoints WHERE token_hash = ?`, this.hash(token)) : undefined
|
|
749
|
+
if (!row?.remote || machinePart(row.remote) !== machine) throw new RelayError("unauthorized", "unknown resume token")
|
|
750
|
+
if (!relabel) return this.resume(token)
|
|
751
|
+
const merged = JSON.stringify(cleanLabels(relabel(JSON.parse(row.labels) as Record<string, string>)))
|
|
752
|
+
return this.tx(() => {
|
|
753
|
+
if (merged !== row.labels) this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, merged, row.id)
|
|
754
|
+
return this.resume(token)
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** every live row of other machines (or of one machine), as views with their machine */
|
|
759
|
+
remoteViews(filter: { except?: string; machine?: string } = {}): RemoteView[] {
|
|
760
|
+
return this.sql
|
|
761
|
+
.all<FederatedRow>(`SELECT * FROM endpoints WHERE remote IS NOT NULL ORDER BY remote`)
|
|
762
|
+
.filter((row) => (!filter.except || machinePart(row.remote!) !== filter.except) && (!filter.machine || machinePart(row.remote!) === filter.machine))
|
|
763
|
+
.map((row) => this.remoteView(row))
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
remoteRowsOf(machine: string): RemoteView[] {
|
|
767
|
+
return this.remoteViews({ machine })
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** a bridge went away: its mirrored rows show offline */
|
|
771
|
+
markOffline(machine: string): MirrorChange[] {
|
|
772
|
+
const rows = this.sql.all<{ id: string }>(
|
|
773
|
+
`UPDATE endpoints SET connections = 0, last_seen = ? WHERE substr(remote, 1, ?) = ? AND token_hash IS NULL AND connections > 0 RETURNING id`,
|
|
774
|
+
this.now(),
|
|
775
|
+
machine.length + 1,
|
|
776
|
+
`${machine}/`,
|
|
777
|
+
)
|
|
778
|
+
return rows.map((row) => ({ view: this.remoteView(this.byId(row.id)!), added: false, presence: true, status: false }))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* a released machine: its rows are retired like a gone handle and can't be resolved or resumed again; with `queue`,
|
|
783
|
+
* their unread deliveries go too, and each message they leave delivered to no one, unless it is a request or has
|
|
784
|
+
* replies, which askers still look up
|
|
785
|
+
*/
|
|
786
|
+
dropMachine(machine: string, options: { queue?: boolean } = {}): number {
|
|
787
|
+
const rows = this.sql.all<{ id: string }>(
|
|
788
|
+
`UPDATE endpoints SET handle = ? || id, remote = NULL, token_hash = NULL, connections = 0 WHERE substr(remote, 1, ?) = ? RETURNING id`,
|
|
789
|
+
GONE_PREFIX,
|
|
790
|
+
machine.length + 1,
|
|
791
|
+
`${machine}/`,
|
|
792
|
+
)
|
|
793
|
+
if (options.queue && this.xinbox) {
|
|
794
|
+
this.xfloor()
|
|
795
|
+
this.sql.all(`DELETE FROM xinbox WHERE substr(endpoint, 1, ?) = ?`, machine.length + 1, `${machine}/`)
|
|
796
|
+
}
|
|
797
|
+
if (options.queue) {
|
|
798
|
+
for (const { id } of rows) {
|
|
799
|
+
for (const { message_id } of this.sql.all<{ message_id: string }>(
|
|
800
|
+
`DELETE FROM deliveries WHERE endpoint = ? AND acked_at IS NULL RETURNING message_id`,
|
|
801
|
+
id,
|
|
802
|
+
)) {
|
|
803
|
+
this.sql.all(
|
|
804
|
+
`DELETE FROM messages WHERE id = ? AND kind != 'request' AND NOT EXISTS (SELECT 1 FROM deliveries WHERE message_id = ?) AND NOT EXISTS (SELECT 1 FROM messages WHERE reply_to = ?)`,
|
|
805
|
+
message_id,
|
|
806
|
+
message_id,
|
|
807
|
+
message_id,
|
|
808
|
+
)
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return rows.length
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* hands a machine's direct-client rows with no connection (or only `endpoint`) to its bridge: their resume tokens are
|
|
817
|
+
* dropped, so the bridge mirrors them, and their unread deliveries get fresh seqs past any bridge's replay cursor;
|
|
818
|
+
* returns those deliveries
|
|
819
|
+
*/
|
|
820
|
+
adoptClaimed(machine: string, endpoint?: string): { seq: number; endpoint: string }[] {
|
|
821
|
+
return this.tx(() => {
|
|
822
|
+
const now = this.now()
|
|
823
|
+
const rows = this.sql.all<{ id: string }>(
|
|
824
|
+
`UPDATE endpoints SET token_hash = NULL WHERE substr(remote, 1, ?) = ? AND token_hash IS NOT NULL AND connections = 0 ${endpoint ? "AND id = ?" : ""} RETURNING id`,
|
|
825
|
+
...[machine.length + 1, `${machine}/`, ...(endpoint ? [endpoint] : [])],
|
|
826
|
+
)
|
|
827
|
+
const adopted: { seq: number; endpoint: string }[] = []
|
|
828
|
+
for (const { id } of rows) {
|
|
829
|
+
const unread = this.sql.all<{ seq: number; message_id: string; expires_at: number }>(
|
|
830
|
+
`DELETE FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ? RETURNING seq, message_id, expires_at`,
|
|
831
|
+
id,
|
|
832
|
+
now,
|
|
833
|
+
)
|
|
834
|
+
for (const row of unread.sort((a, b) => a.seq - b.seq)) {
|
|
835
|
+
const seq = this.insertDelivery(row.message_id, id, row.expires_at)
|
|
836
|
+
adopted.push({ seq, endpoint: id })
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return adopted
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// hops
|
|
844
|
+
|
|
845
|
+
/** the hop a message from this endpoint would get from what it received lately */
|
|
846
|
+
causalHop(endpoint: string): number {
|
|
847
|
+
const row = this.mustEndpoint(endpoint)
|
|
848
|
+
return row.last_received_at !== null && row.last_received_at >= this.now() - LIMITS.causalWindowS * 1000 ? (row.last_received_hop ?? 0) + 1 : 0
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** records that an endpoint received a message of `hop`, as reading its inbox does */
|
|
852
|
+
markReceived(endpoint: string, hop: number): void {
|
|
853
|
+
const now = this.now()
|
|
854
|
+
this.sql.all(
|
|
855
|
+
`UPDATE endpoints SET last_received_at = ?, last_received_hop = MAX(?, CASE WHEN last_received_at >= ? THEN COALESCE(last_received_hop, 0) ELSE 0 END) WHERE id = ?`,
|
|
856
|
+
now,
|
|
857
|
+
hop,
|
|
858
|
+
now - LIMITS.causalWindowS * 1000,
|
|
859
|
+
endpoint,
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// messages across machines
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* why the local policy refuses a hub delivery to the local `endpoint`, or null; checks the message as admit() does and creates its sender's
|
|
867
|
+
* proxy row; relay notices always pass. A scope refusal throws not_found, as send() does; a redelivery of an admitted message skips it.
|
|
868
|
+
*/
|
|
869
|
+
inboundRefusal(endpoint: string, message: Envelope): string | null {
|
|
870
|
+
const recipient = this.mustEndpoint(endpoint)
|
|
871
|
+
this.checkEnvelope(message)
|
|
872
|
+
const sender = this.byId(this.tx(() => this.proxyFor(message.from)))!
|
|
873
|
+
if (sender.kind === "system") return null
|
|
874
|
+
if (!one(this.sql, `SELECT 1 FROM deliveries WHERE message_id = ? AND endpoint = ?`, message.id, endpoint)) {
|
|
875
|
+
const parent =
|
|
876
|
+
message.kind === "reply" && message.reply_to ? one<MessageRow>(this.sql, `SELECT * FROM messages WHERE id = ?`, message.reply_to) : undefined
|
|
877
|
+
const remote = (sender as FederatedRow).remote
|
|
878
|
+
if (remote && isForeign(remote)) this.checkForeignIn(sender, recipient, parent)
|
|
879
|
+
else this.checkScope(sender, recipient, this.address(recipient), parent)
|
|
880
|
+
}
|
|
881
|
+
return this.checkPolicy(this.addressOf(sender.id), this.addressOf(endpoint))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* a message the hub delivered to a local endpoint, stored under the hub's id from a proxy of its sender; idempotent.
|
|
886
|
+
* Throws inbox_full past the inbox cap, which a redelivery, the first reply to the endpoint's own message (as in
|
|
887
|
+
* send()) and relay notices skip.
|
|
888
|
+
*/
|
|
889
|
+
admit(endpoint: string, message: Envelope): { seq: number; duplicate: boolean } {
|
|
890
|
+
this.mustEndpoint(endpoint)
|
|
891
|
+
const checked = this.checkEnvelope(message)
|
|
892
|
+
const stored = this.tx((): { seq: number; duplicate: boolean } | { sender: string; unacked: number } => {
|
|
893
|
+
const sender = this.proxyFor(message.from)
|
|
894
|
+
const had = one<{ seq: number }>(this.sql, `SELECT seq FROM deliveries WHERE message_id = ? AND endpoint = ?`, message.id, endpoint)
|
|
895
|
+
if (had) return { seq: had.seq, duplicate: true }
|
|
896
|
+
const unacked = this.inboxFull(endpoint, sender, message)
|
|
897
|
+
if (unacked !== null) return { sender, unacked }
|
|
898
|
+
this.sql.all(
|
|
899
|
+
`INSERT OR IGNORE INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply)
|
|
900
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
901
|
+
message.id,
|
|
902
|
+
message.thread,
|
|
903
|
+
message.reply_to,
|
|
904
|
+
message.kind,
|
|
905
|
+
sender,
|
|
906
|
+
message.to,
|
|
907
|
+
message.body.text,
|
|
908
|
+
message.body.mime,
|
|
909
|
+
message.data_class,
|
|
910
|
+
JSON.stringify(message.attachments.map((a) => ({ kind: "path", path: a.path }))),
|
|
911
|
+
message.hop,
|
|
912
|
+
checked.createdAt,
|
|
913
|
+
checked.expiresAt,
|
|
914
|
+
message.from.claims.tty === undefined ? null : message.from.claims.tty ? 1 : 0,
|
|
915
|
+
message.require_reply === true && message.kind === "request" ? 1 : null,
|
|
916
|
+
)
|
|
917
|
+
const seq = this.insertDelivery(message.id, endpoint, checked.expiresAt)
|
|
918
|
+
this.audit("inject", endpoint, message.id, { from: message.from.address, hop: message.hop })
|
|
919
|
+
return { seq, duplicate: false }
|
|
920
|
+
})
|
|
921
|
+
if ("unacked" in stored) throw this.refuse(stored.sender, "inbox_full", `${this.addressOf(endpoint)} has ${stored.unacked} unread messages`)
|
|
922
|
+
return stored
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** inject() for the hooks of a local endpoint; another machine's row (mirrored, claimed on the hub, or a sender's proxy) has none and gets nothing */
|
|
926
|
+
override inject(endpoint: string, options: Parameters<RelayCore["inject"]>[1] = {}): ReturnType<RelayCore["inject"]> {
|
|
927
|
+
if (this.machineOf(endpoint) !== null) return { messages: [], offered: [], hold: null, more: 0, unread: 0, block: false }
|
|
928
|
+
return super.inject(endpoint, options)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** offer() for a local endpoint's channel; another machine's row is offered nothing */
|
|
932
|
+
override offer(endpoint: string, options: Parameters<RelayCore["offer"]>[1] = {}): ReturnType<RelayCore["offer"]> {
|
|
933
|
+
if (this.machineOf(endpoint) !== null) return { messages: [], hold: null }
|
|
934
|
+
return super.offer(endpoint, options)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** stopHeld() for a local endpoint; another machine's row has no hooks */
|
|
938
|
+
override stopHeld(endpoint: string, options: Parameters<RelayCore["stopHeld"]>[1] = {}): boolean {
|
|
939
|
+
return this.machineOf(endpoint) === null && super.stopHeld(endpoint, options)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** the endpoint's unacked count when a new delivery from `sender` would pass the inbox cap, else null */
|
|
943
|
+
private inboxFull(endpoint: string, sender: string, message: Envelope): number | null {
|
|
944
|
+
if (this.byId(sender)!.kind === "system") return null
|
|
945
|
+
const parent =
|
|
946
|
+
message.kind === "reply" && message.reply_to
|
|
947
|
+
? one<{ id: string; from_ep: string }>(this.sql, `SELECT id, from_ep FROM messages WHERE id = ?`, message.reply_to)
|
|
948
|
+
: undefined
|
|
949
|
+
const firstReply =
|
|
950
|
+
parent?.from_ep === endpoint &&
|
|
951
|
+
one(this.sql, `SELECT 1 FROM messages WHERE reply_to = ? AND from_ep = ? AND kind = 'reply' AND id != ?`, parent.id, sender, message.id) === undefined
|
|
952
|
+
if (firstReply) return null
|
|
953
|
+
const unacked = one<{ n: number }>(
|
|
954
|
+
this.sql,
|
|
955
|
+
`SELECT COUNT(*) AS n FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ?`,
|
|
956
|
+
endpoint,
|
|
957
|
+
this.now(),
|
|
958
|
+
)!.n
|
|
959
|
+
return unacked >= LIMITS.inboxMax ? unacked : null
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** a send the hub accepted for a local endpoint, stored under the hub's id with no deliveries, so replyTo() and a retried client_id work */
|
|
963
|
+
recordForward(from: string, params: SendParams, result: SendResult, origin: { tty?: boolean } = {}): void {
|
|
964
|
+
const sender = this.mustEndpoint(from)
|
|
965
|
+
const now = this.now()
|
|
966
|
+
const ttl = Number.isInteger(params.ttl_s) ? params.ttl_s! : LIMITS.ttlDefaultS
|
|
967
|
+
const targets = Array.isArray(params.to) ? params.to : [params.to]
|
|
968
|
+
const clientId = cleanText(params.client_id, "client_id", 128)
|
|
969
|
+
const kind = params.kind ?? "message"
|
|
970
|
+
this.tx(() => {
|
|
971
|
+
this.sql.all(
|
|
972
|
+
`INSERT OR IGNORE INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply)
|
|
973
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
974
|
+
result.id,
|
|
975
|
+
result.thread,
|
|
976
|
+
params.reply_to ?? null,
|
|
977
|
+
kind,
|
|
978
|
+
from,
|
|
979
|
+
targets.map(String).join(","),
|
|
980
|
+
params.body,
|
|
981
|
+
params.mime ?? "text/markdown",
|
|
982
|
+
params.data_class ?? "public",
|
|
983
|
+
JSON.stringify((params.attachments ?? []).map((a) => ({ kind: "path", path: a.path }))),
|
|
984
|
+
result.hop,
|
|
985
|
+
now,
|
|
986
|
+
now + ttl * 1000,
|
|
987
|
+
sender.kind === "human" ? (origin.tty === true ? 1 : 0) : null,
|
|
988
|
+
params.require_reply === true && kind === "request" ? 1 : null,
|
|
989
|
+
)
|
|
990
|
+
if (clientId) {
|
|
991
|
+
const { duplicate: _, ...stored } = result
|
|
992
|
+
this.sql.all(
|
|
993
|
+
`INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
994
|
+
from,
|
|
995
|
+
clientId,
|
|
996
|
+
this.forwardHash(params),
|
|
997
|
+
JSON.stringify(stored),
|
|
998
|
+
now,
|
|
999
|
+
)
|
|
1000
|
+
}
|
|
1001
|
+
if (params.reply_to)
|
|
1002
|
+
this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE message_id = ? AND endpoint = ? AND acked_at IS NULL`, now, params.reply_to, from)
|
|
1003
|
+
this.audit("forward", from, result.id, { to: result.deliveries.map((d) => d.address), kind, hop: result.hop, thread: result.thread })
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** the earlier result of a forwarded send with this client_id, or null; a different message under the same client_id is refused */
|
|
1008
|
+
forwarded(from: string, params: SendParams): SendResult | null {
|
|
1009
|
+
const clientId = cleanText(params.client_id, "client_id", 128)
|
|
1010
|
+
if (!clientId) return null
|
|
1011
|
+
const seen = one<{ result: string; payload_hash: string; created_at: number }>(
|
|
1012
|
+
this.sql,
|
|
1013
|
+
`SELECT result, payload_hash, created_at FROM idempotency WHERE from_ep = ? AND client_id = ?`,
|
|
1014
|
+
from,
|
|
1015
|
+
clientId,
|
|
1016
|
+
)
|
|
1017
|
+
if (!seen || seen.created_at < this.now() - LIMITS.idempotencyS * 1000) return null
|
|
1018
|
+
if (seen.payload_hash !== this.forwardHash(params)) throw new RelayError("invalid_params", `client_id ${clientId} was already used for a different message`)
|
|
1019
|
+
return { ...(JSON.parse(seen.result) as SendResult), duplicate: true }
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** one delivery as the hub pushes it, or null when it is gone, acked or expired */
|
|
1023
|
+
delivery(seq: number): Delivery | null {
|
|
1024
|
+
const row = one<MessageRow & { seq: number; recipient: string; remote: string | null; claimed: number }>(
|
|
1025
|
+
this.sql,
|
|
1026
|
+
`SELECT m.*, d.seq AS seq, d.endpoint AS recipient, e.remote AS remote, e.token_hash IS NOT NULL AS claimed FROM deliveries d JOIN messages m ON m.id = d.message_id JOIN endpoints e ON e.id = d.endpoint
|
|
1027
|
+
WHERE d.seq = ? AND d.acked_at IS NULL AND d.expires_at > ?`,
|
|
1028
|
+
seq,
|
|
1029
|
+
this.now(),
|
|
1030
|
+
)
|
|
1031
|
+
if (row)
|
|
1032
|
+
return {
|
|
1033
|
+
seq: row.seq,
|
|
1034
|
+
endpoint: row.remote,
|
|
1035
|
+
recipient: row.recipient,
|
|
1036
|
+
mirrored: row.claimed === 0,
|
|
1037
|
+
message: this.envelope(row, undefined, row.recipient),
|
|
1038
|
+
}
|
|
1039
|
+
return this.xinbox ? (this.xinboxPage(`x.seq = ?`, [seq], 1)[0] ?? null) : null
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** the unacked deliveries to `machine`'s mirrored rows after `after` (xinbox rows after `xafter`), oldest first */
|
|
1043
|
+
pending(machine: string, after: number, limit = 200, xafter = after): Delivery[] {
|
|
1044
|
+
const deliveries = this.sql
|
|
1045
|
+
.all<MessageRow & { seq: number; recipient: string; remote: string }>(
|
|
1046
|
+
`SELECT m.*, d.seq AS seq, d.endpoint AS recipient, e.remote AS remote FROM deliveries d JOIN messages m ON m.id = d.message_id JOIN endpoints e ON e.id = d.endpoint
|
|
1047
|
+
WHERE substr(e.remote, 1, ?) = ? AND e.token_hash IS NULL AND d.acked_at IS NULL AND d.expires_at > ? AND d.seq > ? ORDER BY d.seq LIMIT ?`,
|
|
1048
|
+
machine.length + 1,
|
|
1049
|
+
`${machine}/`,
|
|
1050
|
+
this.now(),
|
|
1051
|
+
after,
|
|
1052
|
+
limit,
|
|
1053
|
+
)
|
|
1054
|
+
.map(
|
|
1055
|
+
(row): Delivery => ({
|
|
1056
|
+
seq: row.seq,
|
|
1057
|
+
endpoint: row.remote,
|
|
1058
|
+
recipient: row.recipient,
|
|
1059
|
+
mirrored: true,
|
|
1060
|
+
message: this.envelope(row, undefined, row.recipient),
|
|
1061
|
+
}),
|
|
1062
|
+
)
|
|
1063
|
+
if (!this.xinbox) return deliveries
|
|
1064
|
+
const others = this.xinboxPage(`substr(x.endpoint, 1, ?) = ? AND x.seq > ?`, [machine.length + 1, `${machine}/`, xafter], limit)
|
|
1065
|
+
return others.length ? [...deliveries, ...others].sort((a, b) => a.seq - b.seq).slice(0, limit) : deliveries
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* acks deliveries of `machine`'s mirrored rows only; each counts as received for the causal hop. A seq of xinbox moves its endpoint's
|
|
1070
|
+
* cursor instead: one row per endpoint for the whole batch, no audit
|
|
1071
|
+
*/
|
|
1072
|
+
ackOn(machine: string, seqs: unknown): number {
|
|
1073
|
+
let acked = 0
|
|
1074
|
+
const batches = new Map<string, Set<number>>()
|
|
1075
|
+
for (const seq of seqList(seqs)) {
|
|
1076
|
+
const row = this.ownDelivery(machine, seq)
|
|
1077
|
+
if (!row) {
|
|
1078
|
+
const endpoint = this.ownXinbox(machine, seq)
|
|
1079
|
+
if (endpoint !== null) batches.set(endpoint, (batches.get(endpoint) ?? new Set()).add(seq))
|
|
1080
|
+
continue
|
|
1081
|
+
}
|
|
1082
|
+
this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE seq = ?`, this.now(), seq)
|
|
1083
|
+
this.markReceived(row.endpoint, row.hop)
|
|
1084
|
+
acked++
|
|
1085
|
+
}
|
|
1086
|
+
if (acked) this.audit("ack", null, null, { machine, count: acked })
|
|
1087
|
+
for (const [endpoint, batch] of batches) acked += this.advanceCursor(endpoint, batch)
|
|
1088
|
+
return acked
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** a delivery `machine`'s local policy refused: acked, and a status notice from the relay to the sender */
|
|
1092
|
+
rejectOn(machine: string, seq: unknown, code: unknown): { notice: { seq: number; endpoint: string } | null } {
|
|
1093
|
+
if (!Number.isInteger(seq)) throw new RelayError("invalid_params", "seq must be an integer")
|
|
1094
|
+
if (typeof code !== "string" || !/^[a-z_]{1,32}$/.test(code)) throw new RelayError("invalid_params", "code is an error code")
|
|
1095
|
+
const row = this.ownDelivery(machine, seq as number)
|
|
1096
|
+
if (!row) {
|
|
1097
|
+
const endpoint = this.ownXinbox(machine, seq as number)
|
|
1098
|
+
if (endpoint === null) throw new RelayError("not_found", `no unacked delivery ${String(seq)} for ${machine}`)
|
|
1099
|
+
this.advanceCursor(endpoint, new Set([seq as number]))
|
|
1100
|
+
return { notice: null }
|
|
1101
|
+
}
|
|
1102
|
+
return this.tx(() => {
|
|
1103
|
+
this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE seq = ?`, this.now(), seq as number)
|
|
1104
|
+
this.audit("reject", row.endpoint, row.message_id, { code, machine })
|
|
1105
|
+
const sender = this.byId(row.from_ep)
|
|
1106
|
+
if (!sender || sender.kind === "system") return { notice: null }
|
|
1107
|
+
const recipient = this.byId(row.endpoint)!
|
|
1108
|
+
const noticeSeq = this.noticeTo(sender.id, `${this.address(recipient)} refused message ${row.message_id} (${code}).`)
|
|
1109
|
+
return { notice: { seq: noticeSeq, endpoint: sender.id } }
|
|
1110
|
+
})
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
protected override audit(event: string, endpoint: string | null, ref: string | null, detail: Record<string, unknown>): void {
|
|
1114
|
+
if (event === "refuse" && this.refusalsPerMinute !== null) {
|
|
1115
|
+
const minute = Math.floor(this.now() / 60_000)
|
|
1116
|
+
const code = String(detail.code)
|
|
1117
|
+
let budget = this.refusals.get(code)
|
|
1118
|
+
if (!budget) {
|
|
1119
|
+
budget = { minute, written: 0, suppressed: 0 }
|
|
1120
|
+
this.refusals.set(code, budget)
|
|
1121
|
+
}
|
|
1122
|
+
if (budget.minute !== minute) Object.assign(budget, { minute, written: 0 })
|
|
1123
|
+
if (budget.written >= this.refusalsPerMinute) {
|
|
1124
|
+
budget.suppressed++
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
budget.written++
|
|
1128
|
+
if (budget.suppressed) {
|
|
1129
|
+
detail = { ...detail, suppressed: budget.suppressed }
|
|
1130
|
+
budget.suppressed = 0
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
super.audit(event, endpoint, ref, detail)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** an audit row from outside the core (hub events: machine binds, releases, revocations) */
|
|
1137
|
+
record(event: string, endpoint: string | null, ref: string | null, detail: Record<string, unknown>): void {
|
|
1138
|
+
this.audit(event, endpoint, ref, detail)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* drops what expired, and with xinbox the rows received over XINBOX_KEEP_MS ago that expired or were acked that long ago, keeping
|
|
1143
|
+
* the highest seq they held as the floor of the next one
|
|
1144
|
+
*/
|
|
1145
|
+
override sweep(): void {
|
|
1146
|
+
super.sweep()
|
|
1147
|
+
if (!this.xinbox) return
|
|
1148
|
+
this.xfloor()
|
|
1149
|
+
const now = this.now()
|
|
1150
|
+
const settled = now - XINBOX_KEEP_MS
|
|
1151
|
+
this.sql.all(
|
|
1152
|
+
`DELETE FROM xinbox WHERE at <= ? AND (expires_at <= ? OR EXISTS (SELECT 1 FROM xcursor c WHERE c.endpoint = xinbox.endpoint
|
|
1153
|
+
AND ((xinbox.seq <= c.acked_seq AND c.at <= ?) OR (xinbox.seq <= c.old_seq AND c.old_at <= ?))))`,
|
|
1154
|
+
settled,
|
|
1155
|
+
now,
|
|
1156
|
+
settled,
|
|
1157
|
+
settled,
|
|
1158
|
+
)
|
|
1159
|
+
this.sql.all(`DELETE FROM xcursor WHERE endpoint NOT IN (SELECT endpoint FROM xinbox)`)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// other accounts (the hosted relay)
|
|
1163
|
+
|
|
1164
|
+
/** the mirrored row of `<machine>/<handle>` (a bridge's, never a direct client's), or null */
|
|
1165
|
+
xendpoint(remote: string): XEndpoint | null {
|
|
1166
|
+
const row = this.byRemote(remote)
|
|
1167
|
+
return row && row.token_hash === null && row.kind !== "system" && !isForeign(remote) ? this.xview(row) : null
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** the mirrored row an endpoint id is, or null for anything else */
|
|
1171
|
+
xsender(endpoint: string): XEndpoint | null {
|
|
1172
|
+
const row = this.byId(endpoint) as FederatedRow | undefined
|
|
1173
|
+
return row?.remote && row.token_hash === null && row.kind !== "system" && !isForeign(row.remote) ? this.xview(row) : null
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** every mirrored row, for discovery */
|
|
1177
|
+
xendpoints(): XEndpoint[] {
|
|
1178
|
+
return this.sql
|
|
1179
|
+
.all<FederatedRow>(`SELECT * FROM endpoints WHERE remote IS NOT NULL AND token_hash IS NULL AND kind != 'system' ORDER BY remote`)
|
|
1180
|
+
.filter((row) => !isForeign(row.remote!))
|
|
1181
|
+
.map((row) => this.xview(row))
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** the xinbox row `id` delivered to `<machine>/<handle>`, or null */
|
|
1185
|
+
xreceived(endpoint: string, id: string): XInboxRow | null {
|
|
1186
|
+
return this.xinbox ? (one<XInboxRow>(this.sql, `SELECT * FROM xinbox WHERE endpoint = ? AND id = ?`, endpoint, id) ?? null) : null
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** the earlier row of a message from `fromAccount` under `clientId`, or null */
|
|
1190
|
+
xduplicate(fromAccount: string, clientId: string): { id: string; acked: boolean } | null {
|
|
1191
|
+
const row = one<{ id: string; seq: number; endpoint: string }>(
|
|
1192
|
+
this.sql,
|
|
1193
|
+
`SELECT id, seq, endpoint FROM xinbox WHERE from_account = ? AND client_id = ?`,
|
|
1194
|
+
fromAccount,
|
|
1195
|
+
clientId,
|
|
1196
|
+
)
|
|
1197
|
+
if (!row) return null
|
|
1198
|
+
const cursor = one<{ acked_seq: number }>(this.sql, `SELECT acked_seq FROM xcursor WHERE endpoint = ?`, row.endpoint)?.acked_seq ?? 0
|
|
1199
|
+
return { id: row.id, acked: row.seq <= cursor }
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** what `fromAccount` sent here: from `sender` to `endpoint` in the last minute and hour (each with its oldest), and since `since` */
|
|
1203
|
+
xcounts(
|
|
1204
|
+
fromAccount: string,
|
|
1205
|
+
sender: string,
|
|
1206
|
+
endpoint: string,
|
|
1207
|
+
since: number,
|
|
1208
|
+
): { minute: number; minuteAt: number | null; hour: number; hourAt: number | null; day: number } {
|
|
1209
|
+
const now = this.now()
|
|
1210
|
+
const pair = one<{ minute: number; minute_at: number | null; hour: number; hour_at: number | null }>(
|
|
1211
|
+
this.sql,
|
|
1212
|
+
`SELECT COALESCE(SUM(at > ?), 0) AS minute, MIN(CASE WHEN at > ? THEN at END) AS minute_at, COUNT(*) AS hour, MIN(at) AS hour_at FROM xinbox
|
|
1213
|
+
WHERE from_account = ? AND sender = ? AND endpoint = ? AND at > ?`,
|
|
1214
|
+
now - 60_000,
|
|
1215
|
+
now - 60_000,
|
|
1216
|
+
fromAccount,
|
|
1217
|
+
sender,
|
|
1218
|
+
endpoint,
|
|
1219
|
+
now - 3_600_000,
|
|
1220
|
+
)!
|
|
1221
|
+
const day = one<{ n: number }>(this.sql, `SELECT COUNT(*) AS n FROM xinbox WHERE from_account = ? AND at >= ?`, fromAccount, since)!.n
|
|
1222
|
+
return { minute: pair.minute, minuteAt: pair.minute_at, hour: pair.hour, hourAt: pair.hour_at, day }
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** an endpoint's unread messages: its unacked, unexpired xinbox rows and deliveries */
|
|
1226
|
+
xunread(endpoint: XEndpoint): number {
|
|
1227
|
+
const now = this.now()
|
|
1228
|
+
return one<{ n: number }>(
|
|
1229
|
+
this.sql,
|
|
1230
|
+
`SELECT (SELECT COUNT(*) FROM xinbox x WHERE x.endpoint = ? AND x.expires_at > ? AND x.seq > COALESCE((SELECT acked_seq FROM xcursor WHERE endpoint = x.endpoint), 0))
|
|
1231
|
+
+ (SELECT COUNT(*) FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ?) AS n`,
|
|
1232
|
+
endpoint.remote,
|
|
1233
|
+
now,
|
|
1234
|
+
endpoint.id,
|
|
1235
|
+
now,
|
|
1236
|
+
)!.n
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** the accounts whose xinbox rows may be read out now; null (unknown) reads none */
|
|
1240
|
+
xsenders(accounts: Set<string> | null): void {
|
|
1241
|
+
this.xfrom = accounts
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** deletes the xinbox rows of every account but `keep`; how many it deleted */
|
|
1245
|
+
xprune(keep: Set<string>): number {
|
|
1246
|
+
if (!this.xinbox) return 0
|
|
1247
|
+
const json = JSON.stringify([...keep])
|
|
1248
|
+
if (!one(this.sql, `SELECT 1 FROM xinbox WHERE from_account NOT IN (SELECT value FROM json_each(?)) LIMIT 1`, json)) return 0
|
|
1249
|
+
this.xfloor()
|
|
1250
|
+
return this.sql.all(`DELETE FROM xinbox WHERE from_account NOT IN (SELECT value FROM json_each(?)) RETURNING seq`, json).length
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
xthreadCount(endpoint: string, thread: string): number {
|
|
1254
|
+
return one<{ n: number }>(this.sql, `SELECT COUNT(*) AS n FROM xinbox WHERE endpoint = ? AND thread = ?`, endpoint, thread)!.n
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** one xinbox row under the next seq; null when a row of the same sender and client_id is there already */
|
|
1258
|
+
xinsert(row: Omit<XInboxRow, "seq">): number | null {
|
|
1259
|
+
return (
|
|
1260
|
+
one<{ seq: number }>(
|
|
1261
|
+
this.sql,
|
|
1262
|
+
`INSERT INTO xinbox (seq, id, from_account, client_id, endpoint, sender, message, thread, grant, expires_at, at) SELECT 1 + ${SEQ_TOP}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE true
|
|
1263
|
+
ON CONFLICT (from_account, client_id) DO NOTHING RETURNING seq`,
|
|
1264
|
+
row.id,
|
|
1265
|
+
row.from_account,
|
|
1266
|
+
row.client_id,
|
|
1267
|
+
row.endpoint,
|
|
1268
|
+
row.sender,
|
|
1269
|
+
row.message,
|
|
1270
|
+
row.thread,
|
|
1271
|
+
row.grant,
|
|
1272
|
+
row.expires_at,
|
|
1273
|
+
row.at,
|
|
1274
|
+
)?.seq ?? null
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/** a value of the cross-account state, written once */
|
|
1279
|
+
xstate(key: string, init: () => string): string {
|
|
1280
|
+
const found = one<{ value: string }>(this.sql, `SELECT value FROM xstate WHERE key = ?`, key)
|
|
1281
|
+
if (found) return found.value
|
|
1282
|
+
const value = init()
|
|
1283
|
+
this.sql.all(`INSERT INTO xstate (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING`, key, value)
|
|
1284
|
+
return one<{ value: string }>(this.sql, `SELECT value FROM xstate WHERE key = ?`, key)!.value
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private xview(row: EndpointRow): XEndpoint {
|
|
1288
|
+
const remote = (row as FederatedRow).remote!
|
|
1289
|
+
return {
|
|
1290
|
+
id: row.id,
|
|
1291
|
+
remote,
|
|
1292
|
+
machine: machinePart(remote),
|
|
1293
|
+
handle: handlePart(remote),
|
|
1294
|
+
kind: row.kind,
|
|
1295
|
+
depth: row.kind === "agent" ? row.depth : null,
|
|
1296
|
+
harness: row.harness,
|
|
1297
|
+
presence: this.view(row).presence,
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** unacked, unexpired xinbox rows of mirrored endpoints from the accounts xsenders() named, as deliveries, by `where` over `x` */
|
|
1302
|
+
private xinboxPage(where: string, params: SqlValue[], limit: number): Delivery[] {
|
|
1303
|
+
if (!this.xfrom?.size) return []
|
|
1304
|
+
return this.sql
|
|
1305
|
+
.all<{ seq: number; endpoint: string; message: string; recipient: string }>(
|
|
1306
|
+
`SELECT x.seq AS seq, x.endpoint AS endpoint, x.message AS message, e.id AS recipient FROM xinbox x JOIN endpoints e ON e.remote = x.endpoint AND e.token_hash IS NULL
|
|
1307
|
+
WHERE ${where} AND x.expires_at > ? AND x.from_account IN (SELECT value FROM json_each(?))
|
|
1308
|
+
AND x.seq > COALESCE((SELECT acked_seq FROM xcursor WHERE endpoint = x.endpoint), 0) ORDER BY x.seq LIMIT ?`,
|
|
1309
|
+
...params,
|
|
1310
|
+
this.now(),
|
|
1311
|
+
JSON.stringify([...this.xfrom]),
|
|
1312
|
+
limit,
|
|
1313
|
+
)
|
|
1314
|
+
.map((row) => ({ seq: row.seq, endpoint: row.endpoint, recipient: row.recipient, mirrored: true, message: JSON.parse(row.message) as Envelope }))
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** the endpoint of an unacked xinbox seq of `machine`, or null */
|
|
1318
|
+
private ownXinbox(machine: string, seq: number): string | null {
|
|
1319
|
+
if (!this.xinbox) return null
|
|
1320
|
+
return (
|
|
1321
|
+
one<{ endpoint: string }>(
|
|
1322
|
+
this.sql,
|
|
1323
|
+
`SELECT x.endpoint AS endpoint FROM xinbox x WHERE x.seq = ? AND substr(x.endpoint, 1, ?) = ? AND x.seq > COALESCE((SELECT acked_seq FROM xcursor WHERE endpoint = x.endpoint), 0)`,
|
|
1324
|
+
seq,
|
|
1325
|
+
machine.length + 1,
|
|
1326
|
+
`${machine}/`,
|
|
1327
|
+
)?.endpoint ?? null
|
|
1328
|
+
)
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* moves an endpoint's cursor over the leading run of its unacked, unexpired rows that `batch` names, in one write or none; a seq past
|
|
1333
|
+
* a row it does not name stays unacked and replays. How many rows it moved over
|
|
1334
|
+
*/
|
|
1335
|
+
private advanceCursor(endpoint: string, batch: Set<number>): number {
|
|
1336
|
+
const now = this.now()
|
|
1337
|
+
const cursor = one<{ acked_seq: number }>(this.sql, `SELECT acked_seq FROM xcursor WHERE endpoint = ?`, endpoint)?.acked_seq ?? 0
|
|
1338
|
+
let to = cursor
|
|
1339
|
+
let moved = 0
|
|
1340
|
+
for (const { seq } of this.sql.all<{ seq: number }>(
|
|
1341
|
+
`SELECT seq FROM xinbox WHERE endpoint = ? AND seq > ? AND seq <= ? AND expires_at > ? ORDER BY seq`,
|
|
1342
|
+
endpoint,
|
|
1343
|
+
cursor,
|
|
1344
|
+
Math.max(...batch),
|
|
1345
|
+
now,
|
|
1346
|
+
)) {
|
|
1347
|
+
if (!batch.has(seq)) break
|
|
1348
|
+
to = seq
|
|
1349
|
+
moved++
|
|
1350
|
+
}
|
|
1351
|
+
if (to === cursor) return 0
|
|
1352
|
+
// the checkpoint (old_seq, old_at) rolls to the previous position at most once a day, so the sweep can tell rows acked a day ago
|
|
1353
|
+
this.sql.all(
|
|
1354
|
+
`INSERT INTO xcursor (endpoint, acked_seq, at) VALUES (?, ?, ?) ON CONFLICT (endpoint) DO UPDATE SET
|
|
1355
|
+
old_seq = CASE WHEN old_at IS NULL OR excluded.at - old_at >= ? THEN acked_seq ELSE old_seq END,
|
|
1356
|
+
old_at = CASE WHEN old_at IS NULL OR excluded.at - old_at >= ? THEN at ELSE old_at END,
|
|
1357
|
+
acked_seq = excluded.acked_seq, at = excluded.at`,
|
|
1358
|
+
endpoint,
|
|
1359
|
+
to,
|
|
1360
|
+
now,
|
|
1361
|
+
XINBOX_KEEP_MS,
|
|
1362
|
+
XINBOX_KEEP_MS,
|
|
1363
|
+
)
|
|
1364
|
+
return moved
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/** when the holder part of a mirrored agent's incarnation moved, drops its xinbox rows and deliveries: they were the earlier holder's */
|
|
1368
|
+
private dropEarlierHolder(row: FederatedRow, incarnation: string | null | undefined): void {
|
|
1369
|
+
const holder = (value: string | null | undefined) => (value?.includes(".") ? value.slice(0, value.indexOf(".")) : null)
|
|
1370
|
+
const was = holder(row.incarnation)
|
|
1371
|
+
const next = holder(incarnation)
|
|
1372
|
+
if (was === null || next === null || was === next) return
|
|
1373
|
+
let xinbox = 0
|
|
1374
|
+
if (this.xinbox) {
|
|
1375
|
+
this.xfloor()
|
|
1376
|
+
xinbox = this.sql.all(`DELETE FROM xinbox WHERE endpoint = ? RETURNING seq`, row.remote).length
|
|
1377
|
+
}
|
|
1378
|
+
const deliveries = this.sql.all(`DELETE FROM deliveries WHERE endpoint = ? RETURNING seq`, row.id).length
|
|
1379
|
+
this.audit("mirror_reset", row.id, null, { target: row.remote, xinbox, deliveries })
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** keeps the highest xinbox seq as the floor of the next one, before rows are deleted */
|
|
1383
|
+
private xfloor(): void {
|
|
1384
|
+
const top = one<{ n: number | null }>(this.sql, `SELECT MAX(seq) AS n FROM xinbox`)!.n
|
|
1385
|
+
const floor = Number(one<{ value: string }>(this.sql, `SELECT value FROM xstate WHERE key = 'seq_floor'`)?.value ?? 0)
|
|
1386
|
+
if (top !== null && top > floor)
|
|
1387
|
+
this.sql.all(`INSERT INTO xstate (key, value) VALUES ('seq_floor', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, String(top))
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// internals
|
|
1391
|
+
|
|
1392
|
+
private ownDelivery(machine: string, seq: number): { endpoint: string; message_id: string; from_ep: string; hop: number } | undefined {
|
|
1393
|
+
return one(
|
|
1394
|
+
this.sql,
|
|
1395
|
+
`SELECT d.endpoint AS endpoint, d.message_id AS message_id, m.from_ep AS from_ep, m.hop AS hop FROM deliveries d JOIN messages m ON m.id = d.message_id JOIN endpoints e ON e.id = d.endpoint
|
|
1396
|
+
WHERE d.seq = ? AND d.acked_at IS NULL AND substr(e.remote, 1, ?) = ? AND e.token_hash IS NULL`,
|
|
1397
|
+
seq,
|
|
1398
|
+
machine.length + 1,
|
|
1399
|
+
`${machine}/`,
|
|
1400
|
+
)
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
private noticeTo(endpoint: string, text: string): number {
|
|
1404
|
+
const system = this.byHandle(SYSTEM_HANDLE)!
|
|
1405
|
+
const now = this.now()
|
|
1406
|
+
const id = newId("msg", now)
|
|
1407
|
+
const expires = now + LIMITS.ttlDefaultS * 1000
|
|
1408
|
+
this.sql.all(
|
|
1409
|
+
`INSERT INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at)
|
|
1410
|
+
VALUES (?, ?, NULL, 'status', ?, ?, ?, 'text/plain', 'public', '[]', 0, ?, ?)`,
|
|
1411
|
+
id,
|
|
1412
|
+
newId("thr", now),
|
|
1413
|
+
system.id,
|
|
1414
|
+
this.addressOf(endpoint),
|
|
1415
|
+
text,
|
|
1416
|
+
now,
|
|
1417
|
+
expires,
|
|
1418
|
+
)
|
|
1419
|
+
return this.insertDelivery(id, endpoint, expires)
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private forwardHash(params: SendParams): string {
|
|
1423
|
+
const targets = (Array.isArray(params.to) ? params.to : [params.to]).map(String).sort()
|
|
1424
|
+
return this.hash(
|
|
1425
|
+
JSON.stringify([
|
|
1426
|
+
"forward",
|
|
1427
|
+
targets,
|
|
1428
|
+
params.body,
|
|
1429
|
+
params.kind ?? "message",
|
|
1430
|
+
params.thread ?? null,
|
|
1431
|
+
params.reply_to ?? null,
|
|
1432
|
+
params.data_class ?? "public",
|
|
1433
|
+
(params.attachments ?? []).map((a) => a.path).sort(),
|
|
1434
|
+
params.mime ?? null,
|
|
1435
|
+
params.ttl_s ?? null,
|
|
1436
|
+
]),
|
|
1437
|
+
)
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** a message from another account passes the recipient's in lists by `acct:`, or as a reply to the recipient's own message to that sender */
|
|
1441
|
+
private checkForeignIn(sender: EndpointRow, recipient: EndpointRow, parent: MessageRow | undefined): void {
|
|
1442
|
+
if (parent && parent.from_ep === recipient.id && parent.to_addr.split(",").some((to) => to.trim() === this.address(sender))) return
|
|
1443
|
+
const admission = this.crossAdmission(recipient.id, "in", this.scopeNode(sender))
|
|
1444
|
+
if (admission.ok) return
|
|
1445
|
+
this.audit("scope_refused", sender.id, null, { from: this.address(sender), to: this.address(recipient), dir: "in", reason: admission.why })
|
|
1446
|
+
throw new RelayError("not_found", `no endpoint ${this.address(recipient)}`)
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** the stand-in row of another account's sender: `@<account>/<machine>/<handle>`, or `@<account>/me` for its human at a terminal */
|
|
1450
|
+
private foreignProxy(from: Envelope["from"]): string {
|
|
1451
|
+
const bad = () => new RelayError("invalid_params", `the sender ${String(from.address)} is not an endpoint of a contact`)
|
|
1452
|
+
const account = from.account
|
|
1453
|
+
if (typeof account !== "string" || !ACCOUNT_HANDLE.test(account) || this.isLocalUser(account) || from.user !== account) throw bad()
|
|
1454
|
+
let remote: string
|
|
1455
|
+
if (from.human === true) {
|
|
1456
|
+
if (from.address !== `${account}/me`) throw bad()
|
|
1457
|
+
remote = `@${account}/me`
|
|
1458
|
+
} else {
|
|
1459
|
+
const parsed = this.foreignAccount(from.address)
|
|
1460
|
+
if (!parsed || parsed.account !== account) throw bad()
|
|
1461
|
+
remote = `@${account}/${parsed.machine}/${parsed.handle}`
|
|
1462
|
+
}
|
|
1463
|
+
const harness = from.claims.harness && validHarness(from.claims.harness) ? from.claims.harness : null
|
|
1464
|
+
const existing = this.byRemote(remote)
|
|
1465
|
+
const id = existing?.id ?? this.insertProxy(remote, from.human === true ? "human" : "agent", harness, from.auth)
|
|
1466
|
+
if (existing && existing.harness !== harness) this.sql.all(`UPDATE endpoints SET harness = ? WHERE id = ?`, harness, id)
|
|
1467
|
+
return id
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
private proxyFor(from: Envelope["from"]): string {
|
|
1471
|
+
if (from.auth === "contact") return this.foreignProxy(from)
|
|
1472
|
+
const prefix = `${from.user}/`
|
|
1473
|
+
if (!from.address.startsWith(prefix)) throw new RelayError("invalid_params", "the sender's address is not of its user")
|
|
1474
|
+
const rest = from.address.slice(prefix.length)
|
|
1475
|
+
const remote = rest.includes("/") ? rest : `${from.user}/${rest}`
|
|
1476
|
+
if (!REMOTE.test(remote)) throw new RelayError("invalid_params", `the sender's address ${from.address} is not valid`)
|
|
1477
|
+
const handle = handlePart(remote)
|
|
1478
|
+
const kind: EndpointKind = handle === "" ? "human" : rest.includes("/") ? "agent" : "system"
|
|
1479
|
+
const harness = from.claims.harness && validHarness(from.claims.harness) ? from.claims.harness : null
|
|
1480
|
+
const model = cleanText(from.claims.model, "model", 128)
|
|
1481
|
+
const existing = this.byRemote(remote)
|
|
1482
|
+
const id = existing?.id ?? this.insertProxy(remote, kind, harness, from.auth)
|
|
1483
|
+
if (!existing || existing.harness !== harness || existing.model !== model || existing.auth !== from.auth) {
|
|
1484
|
+
this.sql.all(`UPDATE endpoints SET harness = ?, model = ?, auth = ? WHERE id = ?`, harness, model, from.auth, id)
|
|
1485
|
+
}
|
|
1486
|
+
return id
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private checkEnvelope(message: Envelope): { createdAt: number; expiresAt: number } {
|
|
1490
|
+
const bad = (what: string) => new RelayError("invalid_params", `the message's ${what} is not valid`)
|
|
1491
|
+
if (!message || typeof message !== "object") throw bad("shape")
|
|
1492
|
+
if (typeof message.id !== "string" || !MESSAGE_ID.test(message.id)) throw bad("id")
|
|
1493
|
+
if (typeof message.thread !== "string" || !message.thread || message.thread.length > 128) throw bad("thread")
|
|
1494
|
+
if (message.reply_to !== null && (typeof message.reply_to !== "string" || message.reply_to.length > 128)) throw bad("reply_to")
|
|
1495
|
+
if (!MESSAGE_KINDS.includes(message.kind)) throw bad("kind")
|
|
1496
|
+
if (typeof message.to !== "string" || message.to.length > 4096) throw bad("to")
|
|
1497
|
+
if (!message.body || typeof message.body.text !== "string" || typeof message.body.mime !== "string" || message.body.mime.length > 64) throw bad("body")
|
|
1498
|
+
if (new TextEncoder().encode(message.body.text).length > LIMITS.bodyBytes)
|
|
1499
|
+
throw new RelayError("too_large", `the body is more than ${LIMITS.bodyBytes} bytes`)
|
|
1500
|
+
if (message.data_class !== "public" && message.data_class !== "private") throw bad("data_class")
|
|
1501
|
+
if (
|
|
1502
|
+
!Array.isArray(message.attachments) ||
|
|
1503
|
+
message.attachments.length > LIMITS.attachments ||
|
|
1504
|
+
message.attachments.some((a) => a?.kind !== "path" || typeof a.path !== "string" || !a.path.startsWith("/"))
|
|
1505
|
+
)
|
|
1506
|
+
throw bad("attachments")
|
|
1507
|
+
if (!Number.isInteger(message.hop) || message.hop < 0 || message.hop > LIMITS.hopMax) throw bad("hop")
|
|
1508
|
+
const from = message.from
|
|
1509
|
+
if (!from || typeof from.address !== "string" || typeof from.user !== "string" || !from.claims || !AUTHS.includes(from.auth)) throw bad("sender")
|
|
1510
|
+
if (from.claims.tty !== undefined && typeof from.claims.tty !== "boolean") throw bad("sender")
|
|
1511
|
+
const createdAt = Date.parse(message.created_at)
|
|
1512
|
+
const expiresAt = Date.parse(message.expires_at)
|
|
1513
|
+
if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt)) throw bad("timestamps")
|
|
1514
|
+
const secret = findSecret(message.body.text)
|
|
1515
|
+
if (secret) throw new RelayError("secret_refused", `the message looks like it carries a credential (${secret})`)
|
|
1516
|
+
return { createdAt, expiresAt }
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
private parseView(value: unknown): ParsedView {
|
|
1520
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new RelayError("invalid_params", "an endpoint is an object")
|
|
1521
|
+
const view = value as Partial<MirrorView>
|
|
1522
|
+
if (typeof view.address !== "string") throw new RelayError("invalid_params", "an endpoint needs an address")
|
|
1523
|
+
const handle = view.address.slice(view.address.lastIndexOf("/") + 1)
|
|
1524
|
+
const kind = view.kind
|
|
1525
|
+
if (kind !== "agent" && kind !== "human") throw new RelayError("invalid_params", "an endpoint's kind is agent or human")
|
|
1526
|
+
if (kind === "human" ? handle !== "" : !validHandle(handle))
|
|
1527
|
+
throw new RelayError("invalid_params", `${JSON.stringify(view.address)} is not a valid endpoint address`)
|
|
1528
|
+
const harness = cleanText(view.harness, "harness", 32)
|
|
1529
|
+
if (harness !== null && !validHarness(harness)) throw new RelayError("invalid_params", `harness ${JSON.stringify(harness)} is not a valid name`)
|
|
1530
|
+
let status: { state: StatusState; note: string } | null = null
|
|
1531
|
+
if (view.status) {
|
|
1532
|
+
if (!STATUS_STATES.includes(view.status.state)) throw new RelayError("invalid_params", `state must be one of ${STATUS_STATES.join(", ")}`)
|
|
1533
|
+
status = { state: view.status.state, note: cleanText(view.status.note, "note", LIMITS.noteChars) ?? "" }
|
|
1534
|
+
}
|
|
1535
|
+
const presence = view.presence ?? "offline"
|
|
1536
|
+
if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle or offline")
|
|
1537
|
+
const parsedSeen = typeof view.last_seen === "string" ? Date.parse(view.last_seen) : Number.NaN
|
|
1538
|
+
const out = {
|
|
1539
|
+
handle,
|
|
1540
|
+
kind,
|
|
1541
|
+
harness,
|
|
1542
|
+
model: cleanText(view.model, "model", 128),
|
|
1543
|
+
cwd: cleanText(view.cwd, "cwd", 1024),
|
|
1544
|
+
labels: JSON.stringify(cleanLabels(view.labels)),
|
|
1545
|
+
status,
|
|
1546
|
+
presence,
|
|
1547
|
+
lastSeen: Number.isFinite(parsedSeen) ? parsedSeen : null,
|
|
1548
|
+
scope: kind === "agent" ? parseScope(view) : {},
|
|
1549
|
+
}
|
|
1550
|
+
const leaked = [handle, out.model, out.cwd, out.labels, status?.note, out.scope.title].map((v) => (v ? findSecret(v) : null)).find(Boolean)
|
|
1551
|
+
if (leaked) throw new RelayError("secret_refused", `an endpoint looks like it carries a credential (${leaked})`)
|
|
1552
|
+
return out
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
private diff(before: FederatedRow | undefined, after: EndpointRow): MirrorChange | null {
|
|
1556
|
+
const view = this.remoteView(after)
|
|
1557
|
+
if (!before) return { view, added: true, presence: true, status: true }
|
|
1558
|
+
const was = this.view(before)
|
|
1559
|
+
const presence = was.presence !== view.presence
|
|
1560
|
+
const status = before.status_state !== after.status_state || before.status_note !== after.status_note
|
|
1561
|
+
const other = (["kind", "harness", "model", "cwd", "labels", "title", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
|
|
1562
|
+
(field) => before[field] !== (after as FederatedRow)[field],
|
|
1563
|
+
)
|
|
1564
|
+
return presence || status || other ? { view, added: false, presence, status } : null
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
private remoteView(row: EndpointRow): RemoteView {
|
|
1568
|
+
const remote = (row as FederatedRow).remote
|
|
1569
|
+
const view: RemoteView = { ...this.view(row), machine: remote ? machinePart(remote) : (this.machine ?? "") }
|
|
1570
|
+
if (row.kind !== "agent") return view
|
|
1571
|
+
const handle = (id: string | null) => {
|
|
1572
|
+
const other = id ? (this.byId(id) as FederatedRow | undefined) : undefined
|
|
1573
|
+
return other?.remote ? handlePart(other.remote) : null
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
...view,
|
|
1577
|
+
tree: row.depth === null ? null : row.tree ? handle(row.tree) : remote ? handlePart(remote) : row.handle,
|
|
1578
|
+
parent_ep: handle(row.parent_ep),
|
|
1579
|
+
depth: row.depth,
|
|
1580
|
+
rules: row.rules ? (JSON.parse(row.rules) as MirrorRules) : null,
|
|
1581
|
+
caps: row.caps ? this.wireCaps(JSON.parse(row.caps) as ScopeCaps) : null,
|
|
1582
|
+
incarnation: (row as FederatedRow).incarnation ?? null,
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* the place each agent of a mirror batch gets, worked out here: a root is a view with no parent and depth 0; a child sits one below its
|
|
1588
|
+
* parent, which must be an agent this machine mirrors; anything else (a parent on another machine or not mirrored, a cycle, a root
|
|
1589
|
+
* claiming depth) is unknown. Undefined when the view says nothing about its place.
|
|
1590
|
+
*/
|
|
1591
|
+
private placer(machine: string, batch: { view: ParsedView; before: FederatedRow | undefined }[]): (handle: string) => Placement | undefined {
|
|
1592
|
+
const agents = new Map(batch.filter((entry) => entry.view.kind === "agent").map((entry) => [entry.view.handle, entry]))
|
|
1593
|
+
const memo = new Map<string, Placement | undefined>()
|
|
1594
|
+
const place = (handle: string, trail: string[]): Placement | undefined => {
|
|
1595
|
+
if (trail.includes(handle)) return UNKNOWN
|
|
1596
|
+
if (memo.has(handle)) return memo.get(handle)
|
|
1597
|
+
const { parent, depth } = agents.get(handle)!.view.scope
|
|
1598
|
+
let result: Placement | undefined
|
|
1599
|
+
if (parent === undefined && depth === undefined) result = undefined
|
|
1600
|
+
else if (parent === undefined) result = UNKNOWN
|
|
1601
|
+
else if (parent === null) result = depth === 0 ? { tree: null, parent_ep: null, depth: 0 } : UNKNOWN
|
|
1602
|
+
else {
|
|
1603
|
+
const row = validHandle(parent) ? this.byRemote(`${machine}/${parent}`) : undefined
|
|
1604
|
+
if (!row || row.token_hash !== null || row.kind !== "agent") result = UNKNOWN
|
|
1605
|
+
else {
|
|
1606
|
+
const up = agents.has(parent)
|
|
1607
|
+
? (place(parent, [...trail, handle]) ?? (agents.get(parent)!.before ? stored(agents.get(parent)!.before!) : UNKNOWN))
|
|
1608
|
+
: stored(row)
|
|
1609
|
+
result = up.depth === null || up.depth + 1 > LIMITS.depthMax ? UNKNOWN : { tree: up.tree ?? row.id, parent_ep: row.id, depth: up.depth + 1 }
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (!trail.length) memo.set(handle, result)
|
|
1613
|
+
return result
|
|
1614
|
+
}
|
|
1615
|
+
return (handle) => place(handle, [])
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/** an agent row's place, rules, caps, title and incarnation after a view: what it left out stays, unless the endpoint is a new incarnation */
|
|
1619
|
+
private scopeColumns(machine: string, scope: ParsedScope, row: FederatedRow, placement: Placement | undefined): Partial<FederatedRow> {
|
|
1620
|
+
const fresh = scope.incarnation !== undefined && scope.incarnation !== null && row.incarnation !== null && scope.incarnation !== row.incarnation
|
|
1621
|
+
const place = placement ?? (fresh ? UNKNOWN : stored(row))
|
|
1622
|
+
let caps = fresh ? null : row.caps
|
|
1623
|
+
if (scope.caps !== undefined) {
|
|
1624
|
+
const next = this.localCaps(machine, scope.caps ?? { caps: [], allow: {} })
|
|
1625
|
+
const merged = fresh || !row.caps ? next : stickyCaps(JSON.parse(row.caps) as ScopeCaps, next)
|
|
1626
|
+
caps = merged.caps.length || Object.keys(merged.allow).length ? JSON.stringify(merged) : null
|
|
1627
|
+
}
|
|
1628
|
+
const rules = scope.rules !== undefined ? scope.rules : fresh ? null : undefined
|
|
1629
|
+
return {
|
|
1630
|
+
...place,
|
|
1631
|
+
rules: rules === undefined ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
|
|
1632
|
+
caps,
|
|
1633
|
+
title: scope.title !== undefined ? scope.title : fresh ? null : row.title,
|
|
1634
|
+
incarnation: scope.incarnation ?? row.incarnation,
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/** wire caps of `machine` with their `ep:<handle>` references as this core's row ids; an endpoint the machine does not mirror is `gone`, which admits nothing */
|
|
1639
|
+
private localCaps(machine: string, caps: ScopeCaps): ScopeCaps {
|
|
1640
|
+
const ref = (value: string) => {
|
|
1641
|
+
if (REFS.has(value)) return value
|
|
1642
|
+
const handle = value.startsWith("ep:") ? value.slice(3) : ""
|
|
1643
|
+
const row = validHandle(handle) ? this.byRemote(`${machine}/${handle}`) : undefined
|
|
1644
|
+
return row && row.token_hash === null && row.kind === "agent" ? row.id : "gone"
|
|
1645
|
+
}
|
|
1646
|
+
return mapRefs(caps, ref)
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** caps with this core's row ids named as `ep:<handle>`, as they cross machines */
|
|
1650
|
+
private wireCaps(caps: ScopeCaps): MirrorCaps {
|
|
1651
|
+
const ref = (value: string) => {
|
|
1652
|
+
if (REFS.has(value)) return value
|
|
1653
|
+
const row = this.byId(value) as FederatedRow | undefined
|
|
1654
|
+
if (row?.kind !== "agent") return "gone"
|
|
1655
|
+
return `ep:${row.remote ? handlePart(row.remote) : row.handle}`
|
|
1656
|
+
}
|
|
1657
|
+
const mapped = mapRefs(caps, ref)
|
|
1658
|
+
return { caps: mapped.caps, allow: mapped.allow }
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
private insertRemote(remote: string, kind: EndpointKind | "system", harness: string | null, auth: RelayAuth | null, now: number): string {
|
|
1662
|
+
const id = newId("ep", now)
|
|
1663
|
+
this.sql.all(
|
|
1664
|
+
`INSERT INTO endpoints (id, handle, kind, harness, labels, created_at, remote, auth) VALUES (?, ?, ?, ?, '{}', ?, ?, ?)`,
|
|
1665
|
+
id,
|
|
1666
|
+
`_r_${id}`,
|
|
1667
|
+
kind,
|
|
1668
|
+
harness,
|
|
1669
|
+
now,
|
|
1670
|
+
remote,
|
|
1671
|
+
auth,
|
|
1672
|
+
)
|
|
1673
|
+
return id
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
private retire(id: string): void {
|
|
1677
|
+
this.sql.all(`UPDATE endpoints SET handle = ? || id, remote = NULL, token_hash = NULL, connections = 0 WHERE id = ?`, GONE_PREFIX, id)
|
|
1678
|
+
this.dropPeek(id)
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/** a new proxy row, within the endpoint cap */
|
|
1682
|
+
private insertProxy(remote: string, kind: EndpointKind | "system", harness: string | null, auth: RelayAuth): string {
|
|
1683
|
+
this.checkCap()
|
|
1684
|
+
return this.insertRemote(remote, kind, harness, auth, this.now())
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
private checkCap(): void {
|
|
1688
|
+
if (this.endpointCap === null) return
|
|
1689
|
+
const live = one<{ n: number }>(
|
|
1690
|
+
this.sql,
|
|
1691
|
+
`SELECT COUNT(*) AS n FROM endpoints WHERE kind != 'system' AND substr(handle, 1, ?) != ?`,
|
|
1692
|
+
GONE_PREFIX.length,
|
|
1693
|
+
GONE_PREFIX,
|
|
1694
|
+
)!.n
|
|
1695
|
+
if (live >= this.endpointCap) throw new RelayError("too_large", `at most ${this.endpointCap} endpoints on this relay`)
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
private checkMachine(machine: string): void {
|
|
1699
|
+
if (typeof machine !== "string" || !MACHINE_NAME.test(machine))
|
|
1700
|
+
throw new RelayError("invalid_params", `machine ${JSON.stringify(machine)} is not a valid name`)
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
private byRemote(remote: string): FederatedRow | undefined {
|
|
1704
|
+
return one<FederatedRow>(this.sql, `SELECT * FROM endpoints WHERE remote = ?`, remote)
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/** a judge status notice to `endpoint` from the relay; its delivery seq */
|
|
1708
|
+
judgeNoticeTo(endpoint: string, text: string): number {
|
|
1709
|
+
return this.noticeTo(endpoint, text)
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const stored = (row: FederatedRow): Placement => ({ tree: row.tree, parent_ep: row.parent_ep, depth: row.depth })
|
|
1714
|
+
|
|
1715
|
+
function mapRefs(caps: ScopeCaps, ref: (value: string) => string): ScopeCaps {
|
|
1716
|
+
const entries = (list: ScopeEntry[]) => list.map((entry) => (entry.via === undefined ? entry : { ...entry, via: ref(entry.via) }))
|
|
1717
|
+
return {
|
|
1718
|
+
caps: caps.caps.map((cap) => ({ ...cap, by: ref(cap.by), sels: entries(cap.sels) })),
|
|
1719
|
+
allow: Object.fromEntries(Object.entries(caps.allow ?? {}).map(([key, list]) => [key, entries(list)])),
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/** a later view's caps over the ones seen before: a cap it leaves out stays, and a cap seen before gains only widened (`via`) entries */
|
|
1724
|
+
function stickyCaps(seen: ScopeCaps, next: ScopeCaps): ScopeCaps {
|
|
1725
|
+
const plain = (entry: ScopeEntry) => entry.via === undefined
|
|
1726
|
+
const caps = next.caps.map((cap) => {
|
|
1727
|
+
const old = seen.caps.find((had) => had.id === cap.id)
|
|
1728
|
+
return old
|
|
1729
|
+
? { ...cap, sels: cap.sels.filter((entry) => !plain(entry) || old.sels.some((had) => plain(had) && had.sel === entry.sel && had.group === entry.group)) }
|
|
1730
|
+
: cap
|
|
1731
|
+
})
|
|
1732
|
+
for (const old of seen.caps) if (!next.caps.some((cap) => cap.id === old.id)) caps.push(old)
|
|
1733
|
+
return { caps, allow: next.allow }
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/** whether `next` drops a cap of `seen` or gives one of them a plain entry: what {@link stickyCaps} would not take without a new incarnation */
|
|
1737
|
+
function loosens(seen: MirrorCaps, next: MirrorCaps): boolean {
|
|
1738
|
+
const plain = (entry: ScopeEntry) => entry.via === undefined
|
|
1739
|
+
return seen.caps.some((old) => {
|
|
1740
|
+
const cap = next.caps.find((c) => c.id === old.id)
|
|
1741
|
+
return !cap || cap.sels.some((entry) => plain(entry) && !old.sels.some((had) => plain(had) && had.sel === entry.sel && had.group === entry.group))
|
|
1742
|
+
})
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const bad = (what: string) => new RelayError("invalid_params", `an endpoint's ${what} is not valid`)
|
|
1746
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value)
|
|
1747
|
+
|
|
1748
|
+
function shortText(value: unknown, what: string, max = 128): string {
|
|
1749
|
+
if (typeof value !== "string" || !value || value.length > max) throw bad(what)
|
|
1750
|
+
return value
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function parseSelectors(value: unknown, what: string): string[] {
|
|
1754
|
+
if (!Array.isArray(value) || value.length > 64 || value.some((sel) => !validSelector(sel))) throw bad(what)
|
|
1755
|
+
return value as string[]
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function parseEntries(value: unknown): ScopeEntry[] {
|
|
1759
|
+
if (!Array.isArray(value) || value.length > 64) throw bad("caps")
|
|
1760
|
+
return value.map((entry) => {
|
|
1761
|
+
if (!isRecord(entry) || !validSelector(entry.sel)) throw bad("caps")
|
|
1762
|
+
return {
|
|
1763
|
+
sel: entry.sel as string,
|
|
1764
|
+
...(entry.via === undefined ? {} : { via: shortText(entry.via, "caps") }),
|
|
1765
|
+
...(entry.group === undefined ? {} : { group: shortText(entry.group, "caps") }),
|
|
1766
|
+
...(entry.base === true ? { base: true as const } : {}),
|
|
1767
|
+
...(entry.member === true ? { member: true as const } : {}),
|
|
1768
|
+
}
|
|
1769
|
+
})
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** the scope fields of an agent's view, checked; a field left out stays undefined */
|
|
1773
|
+
function parseScope(view: Partial<MirrorView>): ParsedScope {
|
|
1774
|
+
const out: ParsedScope = {}
|
|
1775
|
+
const given = (key: keyof MirrorView) => key in view && view[key] !== undefined
|
|
1776
|
+
if (given("tree")) out.tree = view.tree === null ? null : shortText(view.tree, "tree")
|
|
1777
|
+
if (given("parent_ep")) out.parent = view.parent_ep === null ? null : shortText(view.parent_ep, "parent_ep")
|
|
1778
|
+
if (given("depth")) {
|
|
1779
|
+
if (view.depth !== null && (!Number.isInteger(view.depth) || (view.depth as number) < 0 || (view.depth as number) > 1000)) throw bad("depth")
|
|
1780
|
+
out.depth = view.depth ?? null
|
|
1781
|
+
}
|
|
1782
|
+
if (given("rules")) {
|
|
1783
|
+
if (view.rules !== null && !isRecord(view.rules)) throw bad("rules")
|
|
1784
|
+
const rules = view.rules as Record<string, unknown> | null
|
|
1785
|
+
out.rules = rules
|
|
1786
|
+
? {
|
|
1787
|
+
...(rules.in === undefined ? {} : { in: parseSelectors(rules.in, "rules") }),
|
|
1788
|
+
...(rules.out === undefined ? {} : { out: parseSelectors(rules.out, "rules") }),
|
|
1789
|
+
}
|
|
1790
|
+
: null
|
|
1791
|
+
}
|
|
1792
|
+
if (given("caps")) {
|
|
1793
|
+
if (view.caps === null) out.caps = null
|
|
1794
|
+
else {
|
|
1795
|
+
const caps = view.caps as unknown
|
|
1796
|
+
if (!isRecord(caps) || !Array.isArray(caps.caps) || caps.caps.length > 64 || (caps.allow !== undefined && !isRecord(caps.allow))) throw bad("caps")
|
|
1797
|
+
const allow = Object.entries((caps.allow ?? {}) as Record<string, unknown>)
|
|
1798
|
+
if (allow.length > 64) throw bad("caps")
|
|
1799
|
+
out.caps = {
|
|
1800
|
+
caps: caps.caps.map((cap: unknown) => {
|
|
1801
|
+
if (!isRecord(cap) || (cap.dir !== "in" && cap.dir !== "out")) throw bad("caps")
|
|
1802
|
+
return { id: shortText(cap.id, "caps"), dir: cap.dir as ScopeDirection, by: shortText(cap.by, "caps"), sels: parseEntries(cap.sels) }
|
|
1803
|
+
}),
|
|
1804
|
+
allow: Object.fromEntries(allow.map(([key, list]) => [shortText(key, "caps"), parseEntries(list)])),
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
if (new TextEncoder().encode(JSON.stringify([out.rules ?? null, out.caps ?? null])).length > SCOPE_BYTES)
|
|
1809
|
+
throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`)
|
|
1810
|
+
if (given("title"))
|
|
1811
|
+
out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null
|
|
1812
|
+
if (given("incarnation")) {
|
|
1813
|
+
if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation")
|
|
1814
|
+
out.incarnation = view.incarnation ?? null
|
|
1815
|
+
}
|
|
1816
|
+
return out
|
|
1817
|
+
}
|