ankka 0.15.1 → 0.16.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/dist/_proto/ankka/protocol/v1/client_pb.d.ts +365 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.js +61 -5
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +7 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/erasure_pb.d.ts +72 -0
- package/dist/_proto/ankka/protocol/v1/erasure_pb.js +24 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +14 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +136 -1
- package/dist/_proto/ankka/protocol/v1/wasm_pb.js +21 -1
- package/dist/autonomous.d.ts +12 -2
- package/dist/autonomous.js +17 -3
- package/dist/client.d.ts +21 -3
- package/dist/client.js +46 -13
- package/dist/codec.d.ts +2 -0
- package/dist/codec.js +8 -1
- package/dist/consumer.d.ts +5 -0
- package/dist/consumer.js +10 -0
- package/dist/endpoint.d.ts +5 -0
- package/dist/endpoint.js +10 -0
- package/dist/erasure.d.ts +35 -0
- package/dist/erasure.js +10 -0
- package/dist/erasures.d.ts +32 -0
- package/dist/erasures.js +65 -0
- package/dist/graph.d.ts +5 -0
- package/dist/graph.js +10 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/json.js +110 -0
- package/dist/keyring.d.ts +119 -0
- package/dist/keyring.js +417 -0
- package/dist/personal.d.ts +97 -0
- package/dist/personal.js +209 -0
- package/dist/schema.d.ts +13 -0
- package/dist/schema.js +43 -0
- package/dist/server/agent.js +2 -1
- package/dist/server/discovery.d.ts +13 -1
- package/dist/server/discovery.js +14 -2
- package/dist/server/erasure.d.ts +7 -0
- package/dist/server/erasure.js +40 -0
- package/dist/server/eventSourced.js +25 -8
- package/dist/server/keyValue.js +6 -2
- package/dist/server/payloads.d.ts +10 -2
- package/dist/server/payloads.js +36 -4
- package/dist/server/server.d.ts +2 -0
- package/dist/server/server.js +59 -2
- package/dist/server/stateless.js +10 -1
- package/dist/server/workflow.js +8 -2
- package/dist/service.d.ts +12 -1
- package/dist/service.js +60 -3
- package/dist/spec.d.ts +1 -1
- package/dist/spec.js +2 -1
- package/dist/storage.d.ts +48 -2
- package/dist/storage.js +138 -7
- package/dist/testkit/index.d.ts +1 -1
- package/dist/testkit/integration.d.ts +35 -0
- package/dist/testkit/integration.js +106 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/keyring.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// The process's keyring (feature 042): the subject keys its personal fields are sealed and opened
|
|
2
|
+
// under, fetched from the sidecar (`GetSubjectKey`, protocol 1.23) and cached under the runtime's
|
|
3
|
+
// rules (`KeyringSettings`), dropped when the sidecar says a subject was erased (`SubjectNotices`).
|
|
4
|
+
//
|
|
5
|
+
// A codec is synchronous and a fetch is not, so a key is fetched *before* the codec runs: the subjects
|
|
6
|
+
// named in a payload's JSON before it is decoded, the `Personal` values in a value before it is
|
|
7
|
+
// encoded. The codec then finds every key here; one it does not find is `Unavailable`.
|
|
8
|
+
//
|
|
9
|
+
// The process learns its own project from the sidecar, never from configuration it could write: a
|
|
10
|
+
// request for the own project leaves `project` empty, and the answer names the project it is for.
|
|
11
|
+
// The first such answer is the project every envelope the process seals names.
|
|
12
|
+
import { ENVELOPE_VERSION, Erased, originOf, PersonalError, Present, subjectProblem } from "./personal.js";
|
|
13
|
+
export const DEFAULT_KEYRING_SETTINGS = Object.freeze({
|
|
14
|
+
maxKeys: 10_000,
|
|
15
|
+
ttlMillis: 5 * 60_000,
|
|
16
|
+
foreignTtlMillis: 30_000,
|
|
17
|
+
outageBoundMillis: 60_000,
|
|
18
|
+
noticeBoundMillis: 60_000,
|
|
19
|
+
});
|
|
20
|
+
/** The defaults with `given` over them; throws a `RangeError` naming the setting a value breaks. */
|
|
21
|
+
export function keyringSettings(given = {}) {
|
|
22
|
+
const s = { ...DEFAULT_KEYRING_SETTINGS, ...given };
|
|
23
|
+
if (!Number.isInteger(s.maxKeys) || s.maxKeys < 1)
|
|
24
|
+
throw new RangeError(`ankka.keyring.cache.max-keys must be at least 1, was ${s.maxKeys}`);
|
|
25
|
+
const named = [
|
|
26
|
+
["ttlMillis", "cache.ttl"],
|
|
27
|
+
["foreignTtlMillis", "cache.foreign-ttl"],
|
|
28
|
+
["outageBoundMillis", "cache.outage-bound"],
|
|
29
|
+
["noticeBoundMillis", "notice-bound"],
|
|
30
|
+
];
|
|
31
|
+
for (const [field, path] of named) {
|
|
32
|
+
if (!(s[field] > 0))
|
|
33
|
+
throw new RangeError(`ankka.keyring.${path} must be positive, was ${s[field]}ms`);
|
|
34
|
+
}
|
|
35
|
+
if (s.outageBoundMillis > s.noticeBoundMillis) {
|
|
36
|
+
throw new RangeError(`ankka.keyring.cache.outage-bound (${s.outageBoundMillis}ms) must not exceed ankka.keyring.notice-bound (${s.noticeBoundMillis}ms)`);
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze(s);
|
|
39
|
+
}
|
|
40
|
+
/** How many times a fetch overtaken by a notice is made again before it is unavailable. */
|
|
41
|
+
const ATTEMPTS = 3;
|
|
42
|
+
/**
|
|
43
|
+
* The process's key cache, under the runtime's rules: at most `maxKeys`, the least recently used going
|
|
44
|
+
* first; a key of the own project fresh for `ttl`, and still served while the keyring cannot be reached
|
|
45
|
+
* until `outageBound` after it was fetched; a key of another project, which no notice of that project
|
|
46
|
+
* reaches, and a read's erased answer, for `foreignTtl` and no longer. A write's erased answer is a
|
|
47
|
+
* destroyed key, which never changes. An answer whose fetch was in flight when a notice dropped its
|
|
48
|
+
* subject may be the destroyed key: it is never cached, and the key is asked for again.
|
|
49
|
+
*/
|
|
50
|
+
export class Keyring {
|
|
51
|
+
#project;
|
|
52
|
+
#fetch;
|
|
53
|
+
#settings;
|
|
54
|
+
#clock;
|
|
55
|
+
// Insertion order is recency: an entry used is deleted and set again.
|
|
56
|
+
#cache = new Map();
|
|
57
|
+
// Why the last fetch for a subject failed, so the codec can say; never a key.
|
|
58
|
+
#failures = new Map();
|
|
59
|
+
#inFlight = new Map();
|
|
60
|
+
#flights = new Set();
|
|
61
|
+
// Why an answer for the own project failed before any answer named the project.
|
|
62
|
+
#ownFailures = new Map();
|
|
63
|
+
// The installation whose keyring keeps the keys the codec running now reads and writes (FR-032):
|
|
64
|
+
// "" for this one. Set only around a synchronous codec call, by `within`.
|
|
65
|
+
#home = "";
|
|
66
|
+
constructor(options) {
|
|
67
|
+
this.#fetch = options.fetch;
|
|
68
|
+
this.#settings = keyringSettings(options.settings);
|
|
69
|
+
this.#clock = options.clock ?? Date.now;
|
|
70
|
+
}
|
|
71
|
+
/** The service's own project, as the first key answer for it named it; `undefined` until then. */
|
|
72
|
+
get project() {
|
|
73
|
+
return this.#project;
|
|
74
|
+
}
|
|
75
|
+
get settings() {
|
|
76
|
+
return this.#settings;
|
|
77
|
+
}
|
|
78
|
+
/** How many answers are held. */
|
|
79
|
+
get size() {
|
|
80
|
+
return this.#cache.size;
|
|
81
|
+
}
|
|
82
|
+
// ── the cache's rules ──────────────────────────────────────────────────────
|
|
83
|
+
#age(h) {
|
|
84
|
+
return this.#clock() - h.at;
|
|
85
|
+
}
|
|
86
|
+
#fresh(h) {
|
|
87
|
+
if (h.entry.kind === "erased")
|
|
88
|
+
return (h.entry.forWrite && !h.foreign) || this.#age(h) < this.#settings.foreignTtlMillis;
|
|
89
|
+
return this.#age(h) < (h.foreign ? this.#settings.foreignTtlMillis : this.#settings.ttlMillis);
|
|
90
|
+
}
|
|
91
|
+
#servesInOutage(h) {
|
|
92
|
+
return h.entry.kind === "key" && !h.foreign && this.#age(h) < this.#settings.outageBoundMillis;
|
|
93
|
+
}
|
|
94
|
+
/** The entry for `key` while it may be used, now the most recently used; an expired one is forgotten. */
|
|
95
|
+
#held(key) {
|
|
96
|
+
const h = this.#cache.get(key);
|
|
97
|
+
if (h === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
this.#cache.delete(key);
|
|
100
|
+
if (!this.#fresh(h) && !this.#servesInOutage(h))
|
|
101
|
+
return undefined;
|
|
102
|
+
this.#cache.set(key, h);
|
|
103
|
+
return h;
|
|
104
|
+
}
|
|
105
|
+
#put(project, subject, entry, home) {
|
|
106
|
+
const key = cacheKey(project, subject, home);
|
|
107
|
+
this.#cache.delete(key);
|
|
108
|
+
this.#failures.delete(key);
|
|
109
|
+
// Another home's key is held as another project's is: no notice of that keyring reaches this
|
|
110
|
+
// process, so it lives for the foreign TTL and is never served through an outage.
|
|
111
|
+
this.#cache.set(key, { entry, at: this.#clock(), foreign: this.#project === undefined || project !== this.#project || home !== "" });
|
|
112
|
+
while (this.#cache.size > this.#settings.maxKeys) {
|
|
113
|
+
const eldest = this.#cache.keys().next().value;
|
|
114
|
+
if (eldest === undefined)
|
|
115
|
+
break;
|
|
116
|
+
this.#cache.delete(eldest);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ── what the server awaits before the codec runs ───────────────────────────
|
|
120
|
+
/**
|
|
121
|
+
* `work`, run with the keys of `home`'s keyring: a replicated entity's home as its records name it
|
|
122
|
+
* (FR-032), "" for this installation's. `work` is a synchronous codec call, so nothing else runs
|
|
123
|
+
* while the home is set.
|
|
124
|
+
*/
|
|
125
|
+
within(home, work) {
|
|
126
|
+
const before = this.#home;
|
|
127
|
+
this.#home = home;
|
|
128
|
+
try {
|
|
129
|
+
return work();
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
this.#home = before;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Fetches, unless held fresh, the key of every subject the envelopes in `json` name, from `home`'s
|
|
137
|
+
* keyring ("" for this installation's). Never throws.
|
|
138
|
+
*/
|
|
139
|
+
async prefetchJson(json, home = "") {
|
|
140
|
+
const text = typeof json === "string" ? json : Buffer.from(json.buffer, json.byteOffset, json.byteLength).toString("utf8");
|
|
141
|
+
if (!text.includes('"$personal"'))
|
|
142
|
+
return;
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(text);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const wanted = new Map();
|
|
151
|
+
walk(parsed, (node) => {
|
|
152
|
+
if (!isObject(node) || !("$personal" in node))
|
|
153
|
+
return false;
|
|
154
|
+
const { project, subject, c } = node;
|
|
155
|
+
if (node["$personal"] === ENVELOPE_VERSION && typeof c === "string" && typeof project === "string" && subjectProblem(subject) === undefined) {
|
|
156
|
+
wanted.set(cacheKey(project, subject, home), [project, subject]);
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
await Promise.all([...wanted.values()].map(([project, subject]) => this.#ensure(project, subject, false, home)));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Fetches, unless held fresh, the key of every present personal field in `value`, for a write. An
|
|
164
|
+
* erased field needs no key, but its envelope names the own project: until an answer has named it, one
|
|
165
|
+
* is asked for, without `create`. A field read from another project's envelope is sealed again under
|
|
166
|
+
* that project's key (FR-027): its key is fetched, never made. `home` is whose keyring keeps the
|
|
167
|
+
* keys, "" for this installation's; another home's keys are never made. Never throws.
|
|
168
|
+
*/
|
|
169
|
+
async prefetchValue(value, home = "") {
|
|
170
|
+
const subjects = new Set();
|
|
171
|
+
const resealed = new Map();
|
|
172
|
+
let erased;
|
|
173
|
+
walk(value, (node) => {
|
|
174
|
+
if (node instanceof Present || node instanceof Erased) {
|
|
175
|
+
const origin = originOf(node);
|
|
176
|
+
if (origin !== undefined && origin !== this.#project) {
|
|
177
|
+
if (node instanceof Present)
|
|
178
|
+
resealed.set(cacheKey(origin, node.subject, home), [origin, node.subject]);
|
|
179
|
+
}
|
|
180
|
+
else if (node instanceof Present)
|
|
181
|
+
subjects.add(node.subject);
|
|
182
|
+
else
|
|
183
|
+
erased ??= node.subject;
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
});
|
|
188
|
+
const own = this.#project ?? "";
|
|
189
|
+
await Promise.all([...resealed.values()].map(([project, about]) => this.#ensure(project, about, false, home)));
|
|
190
|
+
if (subjects.size === 0 && erased !== undefined && this.#project === undefined)
|
|
191
|
+
return this.#ensure("", erased, false, home);
|
|
192
|
+
await Promise.all([...subjects].map((s) => this.#ensure(own, s, true, home)));
|
|
193
|
+
}
|
|
194
|
+
// ── what the codec reads, synchronously ────────────────────────────────────
|
|
195
|
+
/** The key a write seals `subject`'s field with. Throws `Erased` or `Unavailable`; never fetches. */
|
|
196
|
+
keyForWrite(subject) {
|
|
197
|
+
const project = this.#project;
|
|
198
|
+
if (project === undefined) {
|
|
199
|
+
const why = this.#ownFailures.get(subject);
|
|
200
|
+
throw PersonalError.unavailable(why ?? "this process does not know its project yet: no key answer from the sidecar has named it");
|
|
201
|
+
}
|
|
202
|
+
const key = cacheKey(project, subject, this.#home);
|
|
203
|
+
const entry = this.#held(key)?.entry;
|
|
204
|
+
if (entry?.kind === "key")
|
|
205
|
+
return { project, key: entry.key };
|
|
206
|
+
if (entry?.kind === "erased" && entry.forWrite)
|
|
207
|
+
throw PersonalError.erased(subject);
|
|
208
|
+
throw PersonalError.unavailable(this.#failures.get(key) ?? `no key for '${subject}' was fetched before the write`);
|
|
209
|
+
}
|
|
210
|
+
/** The key a read opens `subject`'s field of `project` with, or `erased`. Throws `Unavailable`; never fetches. */
|
|
211
|
+
keyForRead(project, subject) {
|
|
212
|
+
const key = cacheKey(project, subject, this.#home);
|
|
213
|
+
const entry = this.#held(key)?.entry;
|
|
214
|
+
if (entry !== undefined)
|
|
215
|
+
return entry.kind === "key" ? entry : { kind: "erased" };
|
|
216
|
+
throw PersonalError.unavailable(this.#failures.get(key) ?? `no key for '${subject}' of '${project}' was fetched before the read`);
|
|
217
|
+
}
|
|
218
|
+
// ── notices ────────────────────────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* Forgets every key: what the process does when its notice stream ends or opens again, since the
|
|
221
|
+
* sidecar counts a closed stream as acked and hands a notice sent while none was held never again.
|
|
222
|
+
*/
|
|
223
|
+
dropAll() {
|
|
224
|
+
this.#cache.clear();
|
|
225
|
+
for (const f of this.#flights)
|
|
226
|
+
f.dropped = true;
|
|
227
|
+
}
|
|
228
|
+
/** Forgets `subject`'s key, from whichever home it was fetched: what a notice from the sidecar does. */
|
|
229
|
+
drop(project, subject) {
|
|
230
|
+
const named = cacheKey(project, subject, "");
|
|
231
|
+
for (const key of [...this.#cache.keys()])
|
|
232
|
+
if (key.endsWith(named))
|
|
233
|
+
this.#cache.delete(key);
|
|
234
|
+
for (const f of this.#flights)
|
|
235
|
+
if (f.subject === subject)
|
|
236
|
+
f.dropped = true;
|
|
237
|
+
}
|
|
238
|
+
/** Whether a key, or an erased answer, is held for `subject` and may be used. */
|
|
239
|
+
holds(project, subject, home = "") {
|
|
240
|
+
const h = this.#cache.get(cacheKey(project, subject, home));
|
|
241
|
+
return h !== undefined && (this.#fresh(h) || this.#servesInOutage(h));
|
|
242
|
+
}
|
|
243
|
+
/** `project` empty is the service's own, before its name is known. */
|
|
244
|
+
async #ensure(project, subject, wantCreate, home) {
|
|
245
|
+
// Another installation's keyring is read only to this one: a follower never makes a key.
|
|
246
|
+
const create = wantCreate && home === "";
|
|
247
|
+
const named = project === "" ? this.#project : project;
|
|
248
|
+
if (named !== undefined) {
|
|
249
|
+
const held = this.#cache.get(cacheKey(named, subject, home));
|
|
250
|
+
if (held !== undefined && this.#fresh(held)) {
|
|
251
|
+
if (held.entry.kind === "key")
|
|
252
|
+
return;
|
|
253
|
+
if (held.entry.forWrite || !create)
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const asked = named !== undefined && named === this.#project ? "" : project;
|
|
258
|
+
const flightKey = `${home}\u0000${asked}\u0000${subject}\u0000${create}`;
|
|
259
|
+
const pending = this.#inFlight.get(flightKey);
|
|
260
|
+
if (pending)
|
|
261
|
+
return pending;
|
|
262
|
+
const asking = (async () => {
|
|
263
|
+
let answered = named;
|
|
264
|
+
try {
|
|
265
|
+
let reply;
|
|
266
|
+
for (let attempt = 0; attempt < ATTEMPTS && reply === undefined; attempt++) {
|
|
267
|
+
const flight = { subject, dropped: false };
|
|
268
|
+
this.#flights.add(flight);
|
|
269
|
+
try {
|
|
270
|
+
const got = home === "" ? await this.#fetch(asked, subject, create) : await this.#fetch(asked, subject, create, home);
|
|
271
|
+
if (!flight.dropped)
|
|
272
|
+
reply = got;
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
this.#flights.delete(flight);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (reply === undefined)
|
|
279
|
+
throw new Error(`the key of '${subject}' was dropped by a notice each time it was fetched`);
|
|
280
|
+
// Only an answer to a request for the own project says which project is the own one.
|
|
281
|
+
if (asked === "") {
|
|
282
|
+
if (reply.project === "")
|
|
283
|
+
throw new Error("the sidecar answered a subject key without naming the project it is for");
|
|
284
|
+
if (this.#project === undefined)
|
|
285
|
+
this.#project = reply.project;
|
|
286
|
+
else if (reply.project !== this.#project) {
|
|
287
|
+
throw new Error(`the sidecar answered a key for project '${reply.project}', not this service's '${this.#project}'`);
|
|
288
|
+
}
|
|
289
|
+
this.#ownFailures.delete(subject);
|
|
290
|
+
}
|
|
291
|
+
answered = reply.project !== "" ? reply.project : named;
|
|
292
|
+
if (answered === undefined)
|
|
293
|
+
throw new Error("the sidecar answered a subject key without naming the project it is for");
|
|
294
|
+
const { answer } = reply;
|
|
295
|
+
if (answer.kind === "key") {
|
|
296
|
+
if (answer.key.length !== 32)
|
|
297
|
+
throw new Error(`the keyring answered a key of ${answer.key.length} bytes, not 32`);
|
|
298
|
+
this.#put(answered, subject, { kind: "key", key: answer.key }, home);
|
|
299
|
+
}
|
|
300
|
+
else
|
|
301
|
+
this.#put(answered, subject, { kind: "erased", forWrite: create }, home);
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
// Kept so the codec can say why; asked again by the next prefetch. A held own key the outage
|
|
305
|
+
// bound still allows keeps serving; anything else held is forgotten.
|
|
306
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
307
|
+
if (answered !== undefined) {
|
|
308
|
+
const key = cacheKey(answered, subject, home);
|
|
309
|
+
const held = this.#cache.get(key);
|
|
310
|
+
if (held === undefined || !this.#servesInOutage(held)) {
|
|
311
|
+
this.#cache.delete(key);
|
|
312
|
+
this.#failures.set(key, why);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else
|
|
316
|
+
this.#ownFailures.set(subject, why);
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
this.#inFlight.delete(flightKey);
|
|
320
|
+
}
|
|
321
|
+
})();
|
|
322
|
+
this.#inFlight.set(flightKey, asking);
|
|
323
|
+
return asking;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Keyed by the home as well as the project, so two homes' keys of one subject never mix.
|
|
327
|
+
function cacheKey(project, subject, home) {
|
|
328
|
+
return `${home}\u0000${project}\u0000${subject}`;
|
|
329
|
+
}
|
|
330
|
+
function isObject(v) {
|
|
331
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
332
|
+
}
|
|
333
|
+
/** Visits every node of `value`; `visit` answering true stops the descent below that node. */
|
|
334
|
+
function walk(value, visit) {
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
const go = (node) => {
|
|
337
|
+
if (typeof node !== "object" || node === null)
|
|
338
|
+
return;
|
|
339
|
+
if (seen.has(node))
|
|
340
|
+
return;
|
|
341
|
+
seen.add(node);
|
|
342
|
+
if (visit(node))
|
|
343
|
+
return;
|
|
344
|
+
if (node instanceof Uint8Array)
|
|
345
|
+
return;
|
|
346
|
+
if (Array.isArray(node))
|
|
347
|
+
node.forEach(go);
|
|
348
|
+
else if (node instanceof Map)
|
|
349
|
+
node.forEach((v) => go(v));
|
|
350
|
+
else if (node instanceof Set)
|
|
351
|
+
node.forEach((v) => go(v));
|
|
352
|
+
else
|
|
353
|
+
for (const v of Object.values(node))
|
|
354
|
+
go(v);
|
|
355
|
+
};
|
|
356
|
+
go(value);
|
|
357
|
+
}
|
|
358
|
+
// ── the process's keyring ────────────────────────────────────────────────────
|
|
359
|
+
let installed;
|
|
360
|
+
/** @internal Installs the keyring every codec in this process reads; `undefined` removes it. */
|
|
361
|
+
export function installKeyring(keyring) {
|
|
362
|
+
installed = keyring;
|
|
363
|
+
}
|
|
364
|
+
/** The keyring installed in this process, if any. */
|
|
365
|
+
export function currentKeyring() {
|
|
366
|
+
return installed;
|
|
367
|
+
}
|
|
368
|
+
/** The installed keyring; `Unavailable` when there is none. */
|
|
369
|
+
export function requireKeyring() {
|
|
370
|
+
if (!installed)
|
|
371
|
+
throw PersonalError.unavailable("no keyring is installed in this process (no sidecar speaking protocol 1.23 has discovered it)");
|
|
372
|
+
return installed;
|
|
373
|
+
}
|
|
374
|
+
/** Prefetches for decoding `json` from `home`'s keyring, when a keyring is installed. Never throws. */
|
|
375
|
+
export async function prefetchForDecode(json, home = "") {
|
|
376
|
+
if (installed && json !== undefined && json.length > 0)
|
|
377
|
+
await installed.prefetchJson(json, home);
|
|
378
|
+
}
|
|
379
|
+
/** Prefetches for encoding `value` with `home`'s keys, when a keyring is installed. Never throws. */
|
|
380
|
+
export async function prefetchForEncode(value, home = "") {
|
|
381
|
+
if (installed)
|
|
382
|
+
await installed.prefetchValue(value, home);
|
|
383
|
+
}
|
|
384
|
+
/** `work` with the keys of `home`'s keyring ("" for this installation's), when a keyring is installed. */
|
|
385
|
+
export function withKeysAt(home, work) {
|
|
386
|
+
return installed && home !== "" ? installed.within(home, work) : work();
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Holds `open()`'s notice stream for as long as `signal` is not aborted: each notice drops the subject's
|
|
390
|
+
* key and is then acked. A stream that ends or fails is opened again after `retryMillis`.
|
|
391
|
+
*/
|
|
392
|
+
export async function followNotices(keyring, open, ack, signal, log = () => { }, retryMillis = 1000) {
|
|
393
|
+
while (!signal.aborted) {
|
|
394
|
+
try {
|
|
395
|
+
const stream = open(signal);
|
|
396
|
+
keyring.dropAll();
|
|
397
|
+
for await (const notice of stream) {
|
|
398
|
+
keyring.drop(notice.project, notice.subject);
|
|
399
|
+
await ack(notice.seq);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
if (signal.aborted)
|
|
404
|
+
return;
|
|
405
|
+
log(`ankka: the subject notice stream failed: ${e instanceof Error ? e.message : String(e)}; reopening`);
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
keyring.dropAll();
|
|
409
|
+
}
|
|
410
|
+
if (signal.aborted)
|
|
411
|
+
return;
|
|
412
|
+
await new Promise((resolve) => {
|
|
413
|
+
const t = setTimeout(resolve, retryMillis);
|
|
414
|
+
signal.addEventListener("abort", () => (clearTimeout(t), resolve()), { once: true });
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CommandError } from "./effects/common.ts";
|
|
2
|
+
declare const subjectBrand: unique symbol;
|
|
3
|
+
/** Who a personal field is about: 1 to 253 characters from letters, digits, `.`, `_`, `-`, `/` and `:`. */
|
|
4
|
+
export type DataSubject = string & {
|
|
5
|
+
readonly [subjectBrand]: true;
|
|
6
|
+
};
|
|
7
|
+
/** The rule, in the words every SDK refuses with. */
|
|
8
|
+
export declare const DATA_SUBJECT_RULE = "a data subject is 1 to 253 characters from letters, digits, '.', '_', '-', '/' and ':'";
|
|
9
|
+
/**
|
|
10
|
+
* Why `value` is not a data subject, or `undefined` when it is one. The reason names the rule and never
|
|
11
|
+
* the value, which may be the personal data a caller meant to protect (FR-029).
|
|
12
|
+
*/
|
|
13
|
+
export declare function subjectProblem(value: unknown): string | undefined;
|
|
14
|
+
/** `value` as a data subject; throws a `TypeError` naming the rule when it is not one. */
|
|
15
|
+
export declare function subject(value: string): DataSubject;
|
|
16
|
+
declare const inspect: unique symbol;
|
|
17
|
+
/** A personal field holding its value. */
|
|
18
|
+
export declare class Present<T> {
|
|
19
|
+
readonly kind: "present";
|
|
20
|
+
readonly subject: DataSubject;
|
|
21
|
+
readonly value: T;
|
|
22
|
+
constructor(subject: DataSubject, value: T);
|
|
23
|
+
/** The value; a present field ignores `fallback`. */
|
|
24
|
+
orElse<U>(_fallback: U): T | U;
|
|
25
|
+
get isErased(): false;
|
|
26
|
+
toString(): string;
|
|
27
|
+
[inspect](): string;
|
|
28
|
+
/**
|
|
29
|
+
* The subject alone, never the value: `JSON.stringify` is how an object is logged, and a personal
|
|
30
|
+
* field's textual form holds no value (FR-029). A payload's and an endpoint's forms come from the
|
|
31
|
+
* field's schema, which never calls this.
|
|
32
|
+
*/
|
|
33
|
+
toJSON(): {
|
|
34
|
+
subject: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** A personal field whose data subject was erased: only decoding makes one. */
|
|
38
|
+
export declare class Erased {
|
|
39
|
+
readonly kind: "erased";
|
|
40
|
+
readonly subject: DataSubject;
|
|
41
|
+
constructor(subject: DataSubject);
|
|
42
|
+
orElse<U>(fallback: U): U;
|
|
43
|
+
get isErased(): true;
|
|
44
|
+
toString(): string;
|
|
45
|
+
[inspect](): string;
|
|
46
|
+
toJSON(): {
|
|
47
|
+
subject: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A personal field: read it by `p.kind`, `"present"` or `"erased"`. */
|
|
51
|
+
export type Personal<T> = Present<T> | Erased;
|
|
52
|
+
/** A personal field about `about` holding `value`. */
|
|
53
|
+
export declare function personal<T>(about: DataSubject, value: T): Personal<T>;
|
|
54
|
+
/** @internal An erased field, as decoding makes one. */
|
|
55
|
+
export declare function erased(about: DataSubject): Erased;
|
|
56
|
+
/** @internal Remembers that `field` was read from an envelope of `project`; answers it. */
|
|
57
|
+
export declare function readFrom<F extends Present<unknown> | Erased>(field: F, project: string): F;
|
|
58
|
+
/** @internal The project whose envelope `field` was read from, or `undefined` when a handler made it. */
|
|
59
|
+
export declare function originOf(field: Present<unknown> | Erased): string | undefined;
|
|
60
|
+
export declare function isPersonal(value: unknown): value is Personal<unknown>;
|
|
61
|
+
export type PersonalReason = "unavailable" | "erased" | "corrupt";
|
|
62
|
+
/**
|
|
63
|
+
* Why a personal field could not be written or read. A `CommandError` whose `details.personal` is the
|
|
64
|
+
* reason: `unavailable` (no keyring, or it did not answer), `erased` (a write for an erased data
|
|
65
|
+
* subject), `corrupt` (an envelope that fails authentication). Reading an erased subject is never one.
|
|
66
|
+
*/
|
|
67
|
+
export declare class PersonalError extends CommandError {
|
|
68
|
+
readonly reason: PersonalReason;
|
|
69
|
+
constructor(reason: PersonalReason, message: string);
|
|
70
|
+
static unavailable(why: string): PersonalError;
|
|
71
|
+
static erased(about: string): PersonalError;
|
|
72
|
+
static corrupt(about: string, why: string): PersonalError;
|
|
73
|
+
}
|
|
74
|
+
/** The envelope's version; a reader refuses one it does not know. */
|
|
75
|
+
export declare const ENVELOPE_VERSION = 1;
|
|
76
|
+
/** Subject, project and manifest, each as a 4-byte big-endian length and its UTF-8 bytes. */
|
|
77
|
+
export declare function associatedData(about: string, project: string, manifest: string): Uint8Array;
|
|
78
|
+
/** `nonce ‖ ciphertext ‖ tag`. A nonce is random unless given, which only the fixtures' tests do. */
|
|
79
|
+
export declare function seal(key: Uint8Array, plaintext: Uint8Array, ad: Uint8Array, nonce?: Uint8Array): Uint8Array;
|
|
80
|
+
/** The plaintext, or `undefined` when the tag does not verify under this key and associated data. */
|
|
81
|
+
export declare function open(key: Uint8Array, boxed: Uint8Array, ad: Uint8Array): Uint8Array | undefined;
|
|
82
|
+
/** @internal What a payload is being encoded or decoded under; no scope is the edge. */
|
|
83
|
+
export interface Scope {
|
|
84
|
+
readonly manifest: string;
|
|
85
|
+
readonly nonce?: Uint8Array;
|
|
86
|
+
}
|
|
87
|
+
/** @internal The scope bound now, if any. */
|
|
88
|
+
export declare function currentScope(): Scope | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* @internal Runs `body` (synchronous: a codec's encode or decode) as a serializer writing under
|
|
91
|
+
* `manifest`. `nonce` fixes the nonce every envelope in `body` is sealed with, which only the fixtures'
|
|
92
|
+
* tests may do: a nonce used twice under one key breaks GCM.
|
|
93
|
+
*/
|
|
94
|
+
export declare function withinScope<T>(manifest: string, body: () => T, nonce?: Uint8Array): T;
|
|
95
|
+
/** @internal Runs `body` at the edge, with no scope bound. */
|
|
96
|
+
export declare function outsideScope<T>(body: () => T): T;
|
|
97
|
+
export {};
|