@ccmsg/cli 0.1.0 → 0.2.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/package.json +2 -2
- package/src/auth/admin.ts +64 -0
- package/src/auth/auth.ts +985 -0
- package/src/auth/cbor.ts +150 -0
- package/src/auth/http.ts +280 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/records.ts +394 -0
- package/src/auth/topic.ts +30 -0
- package/src/auth/webauthn.ts +389 -0
- package/src/cli.ts +91 -0
- package/src/dispatch/dispatch.ts +9 -0
- package/src/instance/instance.ts +128 -14
- package/src/mesh/mesh.ts +53 -4
- package/src/mesh/relay.ts +7 -0
- package/src/sessions/registry.ts +18 -16
- package/src/transport/entry.ts +10 -0
- package/src/transport/ws.ts +15 -4
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
AuthRecord,
|
|
5
|
+
AuthTombstone,
|
|
6
|
+
Base64Url,
|
|
7
|
+
CredentialRecord,
|
|
8
|
+
InstanceId,
|
|
9
|
+
Subject,
|
|
10
|
+
Timestamp,
|
|
11
|
+
TokenFamily,
|
|
12
|
+
} from "@ccmsg/protocol";
|
|
13
|
+
import { FAMILY_TOMBSTONE_RETENTION_MS } from "@ccmsg/protocol";
|
|
14
|
+
import { base64UrlDecode, equalBytes, equalStrings } from "./webauthn.ts";
|
|
15
|
+
|
|
16
|
+
export const AUTH_DIR = "auth";
|
|
17
|
+
const RECORDS_FILE = "records.json";
|
|
18
|
+
|
|
19
|
+
/** Where a credential and a family live in the replicated set.
|
|
20
|
+
*
|
|
21
|
+
* Both are under the subject they belong to, which is what makes a removal
|
|
22
|
+
* expressible: a person is removed as a person, and the mark that says so has
|
|
23
|
+
* to refuse every key their credentials and tokens could be written under —
|
|
24
|
+
* including ones this instance has never seen, held by a peer that is
|
|
25
|
+
* partitioned right now (DR-0001 §2.6). A tombstone therefore stands for a
|
|
26
|
+
* prefix rather than for one key. */
|
|
27
|
+
/** A subject as a path segment.
|
|
28
|
+
*
|
|
29
|
+
* A `Subject` is whatever the operator asked for, separators included, and the
|
|
30
|
+
* keys here are matched by prefix — so an unescaped `a/b` would sit under the
|
|
31
|
+
* mark for `a`, and removing one person would remove another. Escaping is what
|
|
32
|
+
* keeps one subject to one segment. */
|
|
33
|
+
function segment(sub: Subject): string {
|
|
34
|
+
return encodeURIComponent(sub);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function credentialKey(sub: Subject, credentialId: Base64Url): string {
|
|
38
|
+
return `${credentialPrefix(sub)}/${credentialId}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function familyKey(sub: Subject, id: string): string {
|
|
42
|
+
return `${familyPrefix(sub)}/${id}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function credentialPrefix(sub: Subject): string {
|
|
46
|
+
return `credential/${segment(sub)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function familyPrefix(sub: Subject): string {
|
|
50
|
+
return `family/${segment(sub)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a tombstone's key covers another key: the key itself, or anything
|
|
54
|
+
* written beneath it. */
|
|
55
|
+
function covers(tombstone: string, key: string): boolean {
|
|
56
|
+
return key === tombstone || key.startsWith(`${tombstone}/`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RecordsDeps {
|
|
60
|
+
/** Where the set is written down (§3.6). */
|
|
61
|
+
readonly dir: string;
|
|
62
|
+
/** This instance's id, which is the one thing that makes a record arriving
|
|
63
|
+
* from a peer refusable on sight: a family this instance minted is written by
|
|
64
|
+
* this instance alone (DR-0001 §2.4). */
|
|
65
|
+
readonly self: InstanceId;
|
|
66
|
+
/** Hand what this instance wrote to the peers, on the `auth_records` topic. */
|
|
67
|
+
readonly publish: (records: readonly AuthRecord[]) => void;
|
|
68
|
+
readonly now?: () => Timestamp;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The credentials, token families and removals every instance holds a copy of
|
|
72
|
+
* (DR-0001 §2.6).
|
|
73
|
+
*
|
|
74
|
+
* Last write wins per key, with one exception that is the whole reason a
|
|
75
|
+
* removal is a record rather than an absence: a tombstone refuses every later
|
|
76
|
+
* write to the keys it covers, so a peer coming back from a partition cannot
|
|
77
|
+
* carry a revoked credential in as news.
|
|
78
|
+
*
|
|
79
|
+
* Written down for the same reason the store is (§3.6): none of it is derived
|
|
80
|
+
* from anything else this instance holds. A credential exists nowhere but here
|
|
81
|
+
* and in the authenticator, and losing a family logs its person out. */
|
|
82
|
+
export class AuthRecords {
|
|
83
|
+
readonly #records = new Map<string, AuthRecord>();
|
|
84
|
+
#loaded = false;
|
|
85
|
+
|
|
86
|
+
constructor(private readonly deps: RecordsDeps) {}
|
|
87
|
+
|
|
88
|
+
#now(): Timestamp {
|
|
89
|
+
return (this.deps.now ?? Date.now)();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Take one record from a peer, or from this instance's own file.
|
|
93
|
+
*
|
|
94
|
+
* Answers whether the set moved, which is what decides whether the change is
|
|
95
|
+
* worth writing down and passing on. */
|
|
96
|
+
accept(record: AuthRecord): boolean {
|
|
97
|
+
this.#load();
|
|
98
|
+
const held = this.#records.get(record.key);
|
|
99
|
+
if (held !== undefined && held.updated_at >= record.updated_at) return false;
|
|
100
|
+
if (this.#refused(record)) return false;
|
|
101
|
+
this.#records.set(record.key, record);
|
|
102
|
+
if (record.body.kind === "tombstone") this.#sweepUnder(record.key);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a tombstone standing over this key refuses it.
|
|
107
|
+
*
|
|
108
|
+
* A tombstone is itself refused by another tombstone covering it, so two
|
|
109
|
+
* removals of the same subject do not fight; the newer one simply does not
|
|
110
|
+
* displace the older, which `accept` has already settled by instant. */
|
|
111
|
+
#refused(record: AuthRecord): boolean {
|
|
112
|
+
for (const held of this.#records.values()) {
|
|
113
|
+
if (held.body.kind !== "tombstone") continue;
|
|
114
|
+
if (held.key === record.key) continue;
|
|
115
|
+
if (covers(held.key, record.key)) return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Drop what a fresh tombstone covers. The mark alone would be enough to
|
|
121
|
+
* refuse later writes, but leaving the records themselves in place would
|
|
122
|
+
* leave a removed person's key usable by this instance. */
|
|
123
|
+
#sweepUnder(tombstone: string): void {
|
|
124
|
+
for (const [key, held] of this.#records) {
|
|
125
|
+
if (held.body.kind === "tombstone") continue;
|
|
126
|
+
if (covers(tombstone, key)) this.#records.delete(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Write one record of this instance's own, and tell the cluster.
|
|
131
|
+
*
|
|
132
|
+
* A local write always displaces what the key holds. Last-write-wins settles
|
|
133
|
+
* a disagreement between instances; this is not one — the writer is the
|
|
134
|
+
* authority for what it writes — and two writes landing in the same
|
|
135
|
+
* millisecond, which a rotation and the mint before it easily do, must not
|
|
136
|
+
* silently drop the second. So the instant is moved past what is held rather
|
|
137
|
+
* than compared against it. */
|
|
138
|
+
write(key: string, body: AuthRecord["body"], now: Timestamp = this.#now()): boolean {
|
|
139
|
+
this.#load();
|
|
140
|
+
const held = this.#records.get(key);
|
|
141
|
+
const at = held === undefined ? now : Math.max(now, held.updated_at + 1);
|
|
142
|
+
const record: AuthRecord = { key, updated_at: at, body };
|
|
143
|
+
if (!this.accept(record)) return false;
|
|
144
|
+
this.#persist();
|
|
145
|
+
this.deps.publish([record]);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Take a batch a peer sent.
|
|
150
|
+
*
|
|
151
|
+
* Answers the subjects a removal arrived for, because a tombstone means more
|
|
152
|
+
* than a record going away: the person it names has connections open here,
|
|
153
|
+
* and they are theirs no longer (DR-0001 §2.6).
|
|
154
|
+
*
|
|
155
|
+
* A family this instance minted is refused whatever the peer says about it.
|
|
156
|
+
* This instance is its only writer, so a copy coming back is a copy of an
|
|
157
|
+
* older state — which is exactly what a failed family looks like from a peer
|
|
158
|
+
* that has not heard yet, and taking it would undo the failure. */
|
|
159
|
+
merge(records: readonly AuthRecord[]): { changed: number; removed: Subject[] } {
|
|
160
|
+
let changed = 0;
|
|
161
|
+
const removed: Subject[] = [];
|
|
162
|
+
for (const record of records) {
|
|
163
|
+
if (record.body.kind === "token_family" && record.body.iss === this.deps.self) continue;
|
|
164
|
+
if (!this.accept(record)) continue;
|
|
165
|
+
changed += 1;
|
|
166
|
+
if (record.body.kind === "tombstone") removed.push(record.body.sub);
|
|
167
|
+
}
|
|
168
|
+
if (changed > 0) this.#persist();
|
|
169
|
+
return { changed, removed };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Remove one person: their credentials and every token they hold.
|
|
173
|
+
*
|
|
174
|
+
* Two marks rather than one because the two halves are kept for different
|
|
175
|
+
* lengths of time. A family expires with its refresh token, so the mark over
|
|
176
|
+
* it only has to outlive the longest one; a credential has no expiry of its
|
|
177
|
+
* own, so the mark over it has none either (DR-0001 §2.6). */
|
|
178
|
+
remove(sub: Subject): AuthRecord[] {
|
|
179
|
+
const at = this.#now();
|
|
180
|
+
const credential: AuthTombstone = { kind: "tombstone", sub, deleted_at: at };
|
|
181
|
+
const family: AuthTombstone = {
|
|
182
|
+
kind: "tombstone",
|
|
183
|
+
sub,
|
|
184
|
+
deleted_at: at,
|
|
185
|
+
expires_at: at + FAMILY_TOMBSTONE_RETENTION_MS,
|
|
186
|
+
};
|
|
187
|
+
const marks: AuthRecord[] = [
|
|
188
|
+
{ key: credentialPrefix(sub), updated_at: at, body: credential },
|
|
189
|
+
{ key: familyPrefix(sub), updated_at: at, body: family },
|
|
190
|
+
];
|
|
191
|
+
for (const mark of marks) this.accept(mark);
|
|
192
|
+
this.#persist();
|
|
193
|
+
this.deps.publish(marks);
|
|
194
|
+
return marks;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether this subject has been removed, which is what a registration for
|
|
198
|
+
* one has to be refused by. */
|
|
199
|
+
removed(sub: Subject): boolean {
|
|
200
|
+
this.#load();
|
|
201
|
+
const held = this.#records.get(credentialPrefix(sub));
|
|
202
|
+
return held?.body.kind === "tombstone";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
credentials(): CredentialRecord[] {
|
|
206
|
+
this.#load();
|
|
207
|
+
const found: CredentialRecord[] = [];
|
|
208
|
+
for (const record of this.#records.values()) {
|
|
209
|
+
if (record.body.kind === "credential") found.push(record.body);
|
|
210
|
+
}
|
|
211
|
+
return found;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The credential an assertion names. Looked up by the id the authenticator
|
|
215
|
+
* signed, which is what lets a person authenticate without naming a subject
|
|
216
|
+
* (DR-0001 §2.5).
|
|
217
|
+
*
|
|
218
|
+
* Compared as bytes rather than as text: base64url is not a canonical
|
|
219
|
+
* spelling — padding may or may not be there, and a decoder accepts more than
|
|
220
|
+
* one string for the same value — so two spellings of one credential id would
|
|
221
|
+
* otherwise be two credentials, and a person would be turned away from their
|
|
222
|
+
* own. */
|
|
223
|
+
credential(credentialId: Base64Url): CredentialRecord | undefined {
|
|
224
|
+
const wanted = base64UrlDecode(credentialId);
|
|
225
|
+
return this.credentials().find((record) =>
|
|
226
|
+
equalBytes(base64UrlDecode(record.credential_id), wanted),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
families(): { key: string; body: TokenFamily }[] {
|
|
231
|
+
this.#load();
|
|
232
|
+
const found: { key: string; body: TokenFamily }[] = [];
|
|
233
|
+
for (const record of this.#records.values()) {
|
|
234
|
+
if (record.body.kind === "token_family") found.push({ key: record.key, body: record.body });
|
|
235
|
+
}
|
|
236
|
+
return found;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The family an access token belongs to, if it is still the standing one and
|
|
240
|
+
* has not run out. */
|
|
241
|
+
byAccess(value: Base64Url, now: Timestamp = this.#now()): TokenFamily | undefined {
|
|
242
|
+
return this.families().find(
|
|
243
|
+
({ body }) => equalStrings(body.access.value, value) && body.access.expires_at > now,
|
|
244
|
+
)?.body;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The family a refresh token belongs to, and which generation it was.
|
|
248
|
+
*
|
|
249
|
+
* The generation before the standing one is answered as `previous` rather
|
|
250
|
+
* than refused: a client whose rotation was lost on the way retries with the
|
|
251
|
+
* value it still holds, and that is not a replay (contract, `TokenFamily`).
|
|
252
|
+
* Anything older matches nothing here, and the caller fails the family. */
|
|
253
|
+
byRefresh(
|
|
254
|
+
value: Base64Url,
|
|
255
|
+
now: Timestamp = this.#now(),
|
|
256
|
+
): { key: string; body: TokenFamily; previous: boolean } | undefined {
|
|
257
|
+
for (const held of this.families()) {
|
|
258
|
+
if (equalStrings(held.body.refresh.value, value) && held.body.refresh.expires_at > now) {
|
|
259
|
+
return { ...held, previous: false };
|
|
260
|
+
}
|
|
261
|
+
const before = held.body.previous_refresh;
|
|
262
|
+
if (before !== undefined && equalStrings(before.value, value) && before.expires_at > now) {
|
|
263
|
+
return { ...held, previous: true };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Fail one family, which is what a reused token does to the whole of it.
|
|
270
|
+
*
|
|
271
|
+
* A tombstone rather than an expired record. The family's `iss` is its only
|
|
272
|
+
* writer, but a peer that was partitioned when this happened still holds the
|
|
273
|
+
* live copy, and an ordinary record would let that copy come back as the
|
|
274
|
+
* newer write when the partition heals. A mark refuses every later write to
|
|
275
|
+
* the key, which is exactly what a revoked family needs. It is kept for the
|
|
276
|
+
* same seven days a removal's is: past the longest refresh token, there is
|
|
277
|
+
* nothing left for a returning peer to revive. */
|
|
278
|
+
fail(key: string): void {
|
|
279
|
+
this.#load();
|
|
280
|
+
const held = this.#records.get(key);
|
|
281
|
+
if (held === undefined || held.body.kind !== "token_family") return;
|
|
282
|
+
const at = this.#now();
|
|
283
|
+
this.write(key, {
|
|
284
|
+
kind: "tombstone",
|
|
285
|
+
sub: held.body.sub,
|
|
286
|
+
deleted_at: at,
|
|
287
|
+
expires_at: at + FAMILY_TOMBSTONE_RETENTION_MS,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The family a value once belonged to, in any generation and whether or not
|
|
292
|
+
* that generation has run out.
|
|
293
|
+
*
|
|
294
|
+
* What `byRefresh` answers is a token that still works. This answers a token
|
|
295
|
+
* that was this family's — which is what a reused value looks like, and what
|
|
296
|
+
* says which instance is allowed to do anything about it (DR-0001 §2.4). */
|
|
297
|
+
owning(value: Base64Url, digest: string): { key: string; body: TokenFamily } | undefined {
|
|
298
|
+
for (const held of this.families()) {
|
|
299
|
+
const before = held.body.previous_refresh;
|
|
300
|
+
if (equalStrings(held.body.refresh.value, value)) return held;
|
|
301
|
+
if (before !== undefined && equalStrings(before.value, value)) return held;
|
|
302
|
+
if ((held.body.retired ?? []).some((one) => equalStrings(one.hash, digest))) return held;
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Every record, for the snapshot a peer's subscription is answered with. */
|
|
308
|
+
all(): AuthRecord[] {
|
|
309
|
+
this.#load();
|
|
310
|
+
this.#expire();
|
|
311
|
+
return [...this.#records.values()];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Drop what has run out: a family past its refresh token, and a family's
|
|
315
|
+
* tombstone past its retention. A credential's tombstone is kept without end.
|
|
316
|
+
*
|
|
317
|
+
* Read rather than swept, like the store's own removals: nothing here runs on
|
|
318
|
+
* a timer (M3), and a record that outlives its window until the next read is
|
|
319
|
+
* one no read can see anyway. */
|
|
320
|
+
#expire(): void {
|
|
321
|
+
const now = this.#now();
|
|
322
|
+
for (const [key, record] of this.#records) {
|
|
323
|
+
const body = record.body;
|
|
324
|
+
if (body.kind === "token_family" && body.refresh.expires_at <= now) {
|
|
325
|
+
this.#records.delete(key);
|
|
326
|
+
}
|
|
327
|
+
if (body.kind === "tombstone" && body.expires_at !== undefined && body.expires_at <= now) {
|
|
328
|
+
this.#records.delete(key);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#load(): void {
|
|
334
|
+
if (this.#loaded) return;
|
|
335
|
+
this.#loaded = true;
|
|
336
|
+
let text: string;
|
|
337
|
+
try {
|
|
338
|
+
text = readFileSync(this.#file(), "utf8");
|
|
339
|
+
} catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
let parsed: unknown;
|
|
343
|
+
try {
|
|
344
|
+
parsed = JSON.parse(text);
|
|
345
|
+
} catch {
|
|
346
|
+
// A file a kill damaged states nothing this instance can act on, and the
|
|
347
|
+
// records it held will come back from the peers that also hold them.
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!Array.isArray(parsed)) return;
|
|
351
|
+
for (const record of parsed as AuthRecord[]) {
|
|
352
|
+
if (typeof record?.key === "string" && typeof record.updated_at === "number") {
|
|
353
|
+
this.#records.set(record.key, record);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
this.#expire();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#persist(): void {
|
|
360
|
+
this.#expire();
|
|
361
|
+
mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 });
|
|
362
|
+
const file = this.#file();
|
|
363
|
+
const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
|
|
364
|
+
// The set holds tokens, so the file is the instance's own to read: it is
|
|
365
|
+
// created with the mode rather than fixed afterwards, so there is no
|
|
366
|
+
// instant at which it stands readable by anyone else.
|
|
367
|
+
writeFileSync(temporary, JSON.stringify([...this.#records.values()]), { mode: 0o600 });
|
|
368
|
+
try {
|
|
369
|
+
renameSync(temporary, file);
|
|
370
|
+
} catch (cause) {
|
|
371
|
+
unlinkSync(temporary);
|
|
372
|
+
throw cause;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#file(): string {
|
|
377
|
+
return join(this.deps.dir, RECORDS_FILE);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Where the records live under a state directory. */
|
|
382
|
+
export function recordsDir(stateDir: string): string {
|
|
383
|
+
return join(stateDir, AUTH_DIR);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The parent of a path, made when a caller wants to write into it. */
|
|
387
|
+
export function ensureDir(file: string): void {
|
|
388
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Whether an instance is the one allowed to write this family (DR-0001 §2.4). */
|
|
392
|
+
export function writes(family: TokenFamily, self: InstanceId): boolean {
|
|
393
|
+
return family.iss === self;
|
|
394
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AuthRecord, InstanceId } from "@ccmsg/protocol";
|
|
2
|
+
import type { Requester } from "../dispatch/index.ts";
|
|
3
|
+
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
4
|
+
import type { AuthRecords } from "./records.ts";
|
|
5
|
+
|
|
6
|
+
/** `auth_records` as the topic mechanism sees it (DR-0001 §2.6).
|
|
7
|
+
*
|
|
8
|
+
* The records are here whether anyone is subscribed or not — they are what
|
|
9
|
+
* authenticates a person, not a watch on something — so there is nothing to
|
|
10
|
+
* start or stop. What subscription decides is only who is told when one
|
|
11
|
+
* changes.
|
|
12
|
+
*
|
|
13
|
+
* The whole set is the snapshot. The topic is `element`-granular, so a frame
|
|
14
|
+
* carries the entries that moved and a subscriber folds them into what it
|
|
15
|
+
* holds; a peer joining folds the lot, which is how a returning instance
|
|
16
|
+
* catches up on what it missed. */
|
|
17
|
+
export class AuthTopic implements UpstreamResource {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly self: InstanceId,
|
|
20
|
+
private readonly records: AuthRecords,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
start(): void {}
|
|
24
|
+
stop(): void {}
|
|
25
|
+
|
|
26
|
+
snapshot(_topic: string, _conn: Requester): readonly TopicValue[] {
|
|
27
|
+
const data: { records: AuthRecord[] } = { records: this.records.all() };
|
|
28
|
+
return [{ instance: this.self, data }];
|
|
29
|
+
}
|
|
30
|
+
}
|