ankka 0.15.0 → 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/personal.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Personal fields (feature 042): a value about one data subject, never held in the clear.
|
|
2
|
+
//
|
|
3
|
+
// Marked in a shape with `s.personal(inner)` and built with `personal(subject("player/8c1f"), value)`.
|
|
4
|
+
// Written by a serializer (a payload sent to the sidecar) it is the personal envelope of
|
|
5
|
+
// protocol/ENCODING.md, sealed with AES-256-GCM under the subject's key; read back it is present, or
|
|
6
|
+
// erased once the key is destroyed. Where no serializer is writing (an endpoint's reply or a caller's
|
|
7
|
+
// body) it is the value with its subject beside it. Its textual form names the subject, never the value.
|
|
8
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
9
|
+
import { CommandError, ErrorCode } from "./effects/common.js";
|
|
10
|
+
/** The rule, in the words every SDK refuses with. */
|
|
11
|
+
export const DATA_SUBJECT_RULE = "a data subject is 1 to 253 characters from letters, digits, '.', '_', '-', '/' and ':'";
|
|
12
|
+
const SUBJECT = /^[A-Za-z0-9._/:-]{1,253}$/;
|
|
13
|
+
/**
|
|
14
|
+
* Why `value` is not a data subject, or `undefined` when it is one. The reason names the rule and never
|
|
15
|
+
* the value, which may be the personal data a caller meant to protect (FR-029).
|
|
16
|
+
*/
|
|
17
|
+
export function subjectProblem(value) {
|
|
18
|
+
if (typeof value === "string" && SUBJECT.test(value))
|
|
19
|
+
return undefined;
|
|
20
|
+
return `the value given is not a data subject: ${DATA_SUBJECT_RULE}`;
|
|
21
|
+
}
|
|
22
|
+
/** `value` as a data subject; throws a `TypeError` naming the rule when it is not one. */
|
|
23
|
+
export function subject(value) {
|
|
24
|
+
const problem = subjectProblem(value);
|
|
25
|
+
if (problem !== undefined)
|
|
26
|
+
throw new TypeError(problem);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
// ── the field ────────────────────────────────────────────────────────────────
|
|
30
|
+
const inspect = Symbol.for("nodejs.util.inspect.custom");
|
|
31
|
+
/** A personal field holding its value. */
|
|
32
|
+
export class Present {
|
|
33
|
+
kind = "present";
|
|
34
|
+
subject;
|
|
35
|
+
value;
|
|
36
|
+
constructor(subject, value) {
|
|
37
|
+
this.subject = subject;
|
|
38
|
+
this.value = value;
|
|
39
|
+
Object.freeze(this);
|
|
40
|
+
}
|
|
41
|
+
/** The value; a present field ignores `fallback`. */
|
|
42
|
+
orElse(_fallback) {
|
|
43
|
+
return this.value;
|
|
44
|
+
}
|
|
45
|
+
get isErased() {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
toString() {
|
|
49
|
+
return `Personal(${this.subject})`;
|
|
50
|
+
}
|
|
51
|
+
[inspect]() {
|
|
52
|
+
return this.toString();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The subject alone, never the value: `JSON.stringify` is how an object is logged, and a personal
|
|
56
|
+
* field's textual form holds no value (FR-029). A payload's and an endpoint's forms come from the
|
|
57
|
+
* field's schema, which never calls this.
|
|
58
|
+
*/
|
|
59
|
+
toJSON() {
|
|
60
|
+
return { subject: this.subject };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** A personal field whose data subject was erased: only decoding makes one. */
|
|
64
|
+
export class Erased {
|
|
65
|
+
kind = "erased";
|
|
66
|
+
subject;
|
|
67
|
+
constructor(subject) {
|
|
68
|
+
this.subject = subject;
|
|
69
|
+
Object.freeze(this);
|
|
70
|
+
}
|
|
71
|
+
orElse(fallback) {
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
get isErased() {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
toString() {
|
|
78
|
+
return `Personal(${this.subject})`;
|
|
79
|
+
}
|
|
80
|
+
[inspect]() {
|
|
81
|
+
return this.toString();
|
|
82
|
+
}
|
|
83
|
+
toJSON() {
|
|
84
|
+
return { subject: this.subject };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** A personal field about `about` holding `value`. */
|
|
88
|
+
export function personal(about, value) {
|
|
89
|
+
const problem = subjectProblem(about);
|
|
90
|
+
if (problem !== undefined)
|
|
91
|
+
throw new TypeError(problem);
|
|
92
|
+
return new Present(about, value);
|
|
93
|
+
}
|
|
94
|
+
/** @internal An erased field, as decoding makes one. */
|
|
95
|
+
export function erased(about) {
|
|
96
|
+
return new Erased(about);
|
|
97
|
+
}
|
|
98
|
+
// The project of each field read from an envelope, by the field's identity and held weakly (FR-027):
|
|
99
|
+
// a field a handler makes, even with the same value, is a different object and the service's own.
|
|
100
|
+
const origins = new WeakMap();
|
|
101
|
+
/** @internal Remembers that `field` was read from an envelope of `project`; answers it. */
|
|
102
|
+
export function readFrom(field, project) {
|
|
103
|
+
origins.set(field, project);
|
|
104
|
+
return field;
|
|
105
|
+
}
|
|
106
|
+
/** @internal The project whose envelope `field` was read from, or `undefined` when a handler made it. */
|
|
107
|
+
export function originOf(field) {
|
|
108
|
+
return origins.get(field);
|
|
109
|
+
}
|
|
110
|
+
export function isPersonal(value) {
|
|
111
|
+
return value instanceof Present || value instanceof Erased;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Why a personal field could not be written or read. A `CommandError` whose `details.personal` is the
|
|
115
|
+
* reason: `unavailable` (no keyring, or it did not answer), `erased` (a write for an erased data
|
|
116
|
+
* subject), `corrupt` (an envelope that fails authentication). Reading an erased subject is never one.
|
|
117
|
+
*/
|
|
118
|
+
export class PersonalError extends CommandError {
|
|
119
|
+
reason;
|
|
120
|
+
constructor(reason, message) {
|
|
121
|
+
const code = reason === "unavailable" ? ErrorCode.Unavailable : reason === "erased" ? ErrorCode.Conflict : ErrorCode.Internal;
|
|
122
|
+
super({ message, code, details: { personal: reason } });
|
|
123
|
+
this.name = "PersonalError";
|
|
124
|
+
this.reason = reason;
|
|
125
|
+
}
|
|
126
|
+
static unavailable(why) {
|
|
127
|
+
return new PersonalError("unavailable", `the keyring is unavailable: ${why}`);
|
|
128
|
+
}
|
|
129
|
+
static erased(about) {
|
|
130
|
+
return new PersonalError("erased", `the data subject '${about}' is erased; nothing was written`);
|
|
131
|
+
}
|
|
132
|
+
static corrupt(about, why) {
|
|
133
|
+
return new PersonalError("corrupt", `the personal envelope of '${about}' is corrupt: ${why}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ── the envelope's cipher ────────────────────────────────────────────────────
|
|
137
|
+
/** The envelope's version; a reader refuses one it does not know. */
|
|
138
|
+
export const ENVELOPE_VERSION = 1;
|
|
139
|
+
const NONCE_BYTES = 12;
|
|
140
|
+
const TAG_BYTES = 16;
|
|
141
|
+
const utf8 = new TextEncoder();
|
|
142
|
+
/** Subject, project and manifest, each as a 4-byte big-endian length and its UTF-8 bytes. */
|
|
143
|
+
export function associatedData(about, project, manifest) {
|
|
144
|
+
const parts = [about, project, manifest].map((p) => utf8.encode(p));
|
|
145
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + 4 + p.length, 0));
|
|
146
|
+
const view = new DataView(out.buffer);
|
|
147
|
+
let at = 0;
|
|
148
|
+
for (const p of parts) {
|
|
149
|
+
view.setUint32(at, p.length, false);
|
|
150
|
+
out.set(p, at + 4);
|
|
151
|
+
at += 4 + p.length;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** `nonce ‖ ciphertext ‖ tag`. A nonce is random unless given, which only the fixtures' tests do. */
|
|
156
|
+
export function seal(key, plaintext, ad, nonce) {
|
|
157
|
+
const iv = nonce ?? randomBytes(NONCE_BYTES);
|
|
158
|
+
if (iv.length !== NONCE_BYTES)
|
|
159
|
+
throw new RangeError(`a nonce is ${NONCE_BYTES} bytes, was ${iv.length}`);
|
|
160
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
161
|
+
cipher.setAAD(ad);
|
|
162
|
+
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
163
|
+
return new Uint8Array(Buffer.concat([iv, body, cipher.getAuthTag()]));
|
|
164
|
+
}
|
|
165
|
+
/** The plaintext, or `undefined` when the tag does not verify under this key and associated data. */
|
|
166
|
+
export function open(key, boxed, ad) {
|
|
167
|
+
if (boxed.length < NONCE_BYTES + TAG_BYTES)
|
|
168
|
+
return undefined;
|
|
169
|
+
try {
|
|
170
|
+
const decipher = createDecipheriv("aes-256-gcm", key, boxed.subarray(0, NONCE_BYTES));
|
|
171
|
+
decipher.setAAD(ad);
|
|
172
|
+
decipher.setAuthTag(boxed.subarray(boxed.length - TAG_BYTES));
|
|
173
|
+
return new Uint8Array(Buffer.concat([decipher.update(boxed.subarray(NONCE_BYTES, boxed.length - TAG_BYTES)), decipher.final()]));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let bound;
|
|
180
|
+
/** @internal The scope bound now, if any. */
|
|
181
|
+
export function currentScope() {
|
|
182
|
+
return bound;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* @internal Runs `body` (synchronous: a codec's encode or decode) as a serializer writing under
|
|
186
|
+
* `manifest`. `nonce` fixes the nonce every envelope in `body` is sealed with, which only the fixtures'
|
|
187
|
+
* tests may do: a nonce used twice under one key breaks GCM.
|
|
188
|
+
*/
|
|
189
|
+
export function withinScope(manifest, body, nonce) {
|
|
190
|
+
const before = bound;
|
|
191
|
+
bound = nonce === undefined ? { manifest } : { manifest, nonce };
|
|
192
|
+
try {
|
|
193
|
+
return body();
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
bound = before;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** @internal Runs `body` at the edge, with no scope bound. */
|
|
200
|
+
export function outsideScope(body) {
|
|
201
|
+
const before = bound;
|
|
202
|
+
bound = undefined;
|
|
203
|
+
try {
|
|
204
|
+
return body();
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
bound = before;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Duration, Instant, LocalDate, LocalDateTime } from "./time.ts";
|
|
2
|
+
import type { Personal } from "./personal.ts";
|
|
2
3
|
/** The one value of type `Done`: a handler that has nothing to say. */
|
|
3
4
|
export declare const done: Readonly<{
|
|
4
5
|
readonly done: true;
|
|
@@ -32,6 +33,9 @@ export type Shape = {
|
|
|
32
33
|
} | {
|
|
33
34
|
readonly kind: "lazy";
|
|
34
35
|
readonly thunk: () => Schema;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: "personal";
|
|
38
|
+
readonly inner: Schema;
|
|
35
39
|
};
|
|
36
40
|
interface Typed<T> {
|
|
37
41
|
/** Phantom: carries the TypeScript type. Never set. */
|
|
@@ -90,6 +94,13 @@ export declare const s: Readonly<{
|
|
|
90
94
|
sumType<C extends Readonly<Record<string, Readonly<Record<string, Schema>>>>>(name: string, cases: C): Schema<InferCases<C>>;
|
|
91
95
|
/** A fieldless enumeration used as a field: `{"type":"Ready"}` on the wire, `"Ready"` in TypeScript. */
|
|
92
96
|
enumeration<const V extends readonly [string, ...string[]]>(name: string, ...values: V): Schema<V[number]>;
|
|
97
|
+
/**
|
|
98
|
+
* A personal field (feature 042): `s.personal(s.string)` is a `Personal<string>`, built with
|
|
99
|
+
* `personal(subject("player/8c1f"), value)`. A payload holds it as the personal envelope, sealed under
|
|
100
|
+
* the data subject's key; an endpoint's reply or body as `{"subject": s, "value": v}`, or
|
|
101
|
+
* `{"subject": s}` once the subject is erased.
|
|
102
|
+
*/
|
|
103
|
+
personal<T>(inner: Schema<T>): Schema<Personal<T>>;
|
|
93
104
|
/** A reference to a schema declared later, for recursive shapes: `s.list(s.lazy(() => Tree))`. */
|
|
94
105
|
lazy<T>(thunk: () => Schema<T>): Schema<T>;
|
|
95
106
|
}>;
|
|
@@ -109,4 +120,6 @@ export declare function defaultManifest(schema: Schema): string;
|
|
|
109
120
|
* unknown properties; integers are `integer`; instants are `string` with `format: date-time`.
|
|
110
121
|
*/
|
|
111
122
|
export declare function toJsonSchema(schema: Schema): Record<string, unknown>;
|
|
123
|
+
/** Whether `schema` holds a personal field anywhere in it. */
|
|
124
|
+
export declare function holdsPersonal(schema: Schema, seen?: Set<Schema>): boolean;
|
|
112
125
|
export {};
|
package/dist/schema.js
CHANGED
|
@@ -82,6 +82,15 @@ export const s = Object.freeze({
|
|
|
82
82
|
throw new SchemaError(`enumeration ${name} repeats a value`);
|
|
83
83
|
return Object.freeze({ kind: "enumeration", name, values: Object.freeze([...values]) });
|
|
84
84
|
},
|
|
85
|
+
/**
|
|
86
|
+
* A personal field (feature 042): `s.personal(s.string)` is a `Personal<string>`, built with
|
|
87
|
+
* `personal(subject("player/8c1f"), value)`. A payload holds it as the personal envelope, sealed under
|
|
88
|
+
* the data subject's key; an endpoint's reply or body as `{"subject": s, "value": v}`, or
|
|
89
|
+
* `{"subject": s}` once the subject is erased.
|
|
90
|
+
*/
|
|
91
|
+
personal(inner) {
|
|
92
|
+
return Object.freeze({ kind: "personal", inner });
|
|
93
|
+
},
|
|
85
94
|
/** A reference to a schema declared later, for recursive shapes: `s.list(s.lazy(() => Tree))`. */
|
|
86
95
|
lazy(thunk) {
|
|
87
96
|
return Object.freeze({ kind: "lazy", thunk });
|
|
@@ -114,6 +123,8 @@ export function describe(schema) {
|
|
|
114
123
|
return `list<${describe(r.inner)}>`;
|
|
115
124
|
case "stringMap":
|
|
116
125
|
return `map<string, ${describe(r.inner)}>`;
|
|
126
|
+
case "personal":
|
|
127
|
+
return `personal<${describe(r.inner)}>`;
|
|
117
128
|
default:
|
|
118
129
|
return r.kind;
|
|
119
130
|
}
|
|
@@ -132,6 +143,8 @@ export function defaultManifest(schema) {
|
|
|
132
143
|
return `list[${defaultManifest(r.inner)}]`;
|
|
133
144
|
case "stringMap":
|
|
134
145
|
return `map[${defaultManifest(r.inner)}]`;
|
|
146
|
+
case "personal":
|
|
147
|
+
return `personal[${defaultManifest(r.inner)}]`;
|
|
135
148
|
default:
|
|
136
149
|
return r.kind;
|
|
137
150
|
}
|
|
@@ -186,6 +199,36 @@ export function toJsonSchema(schema) {
|
|
|
186
199
|
};
|
|
187
200
|
case "enumeration":
|
|
188
201
|
return { type: "object", properties: { type: { enum: [...r.values] } }, required: ["type"], additionalProperties: false };
|
|
202
|
+
case "personal":
|
|
203
|
+
// The edge form: the value with its subject beside it, or the subject alone once erased.
|
|
204
|
+
return {
|
|
205
|
+
type: "object",
|
|
206
|
+
properties: { subject: { type: "string", pattern: "^[A-Za-z0-9._/:-]{1,253}$" }, value: toJsonSchema(r.inner) },
|
|
207
|
+
required: ["subject"],
|
|
208
|
+
additionalProperties: false,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Whether `schema` holds a personal field anywhere in it. */
|
|
213
|
+
export function holdsPersonal(schema, seen = new Set()) {
|
|
214
|
+
if (seen.has(schema))
|
|
215
|
+
return false;
|
|
216
|
+
seen.add(schema);
|
|
217
|
+
switch (schema.kind) {
|
|
218
|
+
case "personal":
|
|
219
|
+
return true;
|
|
220
|
+
case "lazy":
|
|
221
|
+
return holdsPersonal(schema.thunk(), seen);
|
|
222
|
+
case "option":
|
|
223
|
+
case "list":
|
|
224
|
+
case "stringMap":
|
|
225
|
+
return holdsPersonal(schema.inner, seen);
|
|
226
|
+
case "record":
|
|
227
|
+
return Object.values(schema.fields).some((f) => holdsPersonal(f, seen));
|
|
228
|
+
case "sumType":
|
|
229
|
+
return Object.values(schema.cases).some((fields) => Object.values(fields).some((f) => holdsPersonal(f, seen)));
|
|
230
|
+
default:
|
|
231
|
+
return false;
|
|
189
232
|
}
|
|
190
233
|
}
|
|
191
234
|
function objectSchema(fields) {
|
package/dist/server/agent.js
CHANGED
|
@@ -10,7 +10,7 @@ import { ErrorCode } from "../effects/common.js";
|
|
|
10
10
|
import { decodeJsonValue, reviver } from "../json.js";
|
|
11
11
|
import { errorCodeToProto } from "../kinds.js";
|
|
12
12
|
import { decodeResult } from "../autonomous.js";
|
|
13
|
-
import { decodePayload } from "./payloads.js";
|
|
13
|
+
import { decodePayload, prefetchPayload } from "./payloads.js";
|
|
14
14
|
function messageOf(e) {
|
|
15
15
|
return e instanceof Error ? e.message : String(e);
|
|
16
16
|
}
|
|
@@ -33,6 +33,7 @@ export async function handlePlan(req, ctx) {
|
|
|
33
33
|
const agent = new registered.cls();
|
|
34
34
|
agent._bind(req.sessionId, metadata, ctx.client.withMetadata(metadata));
|
|
35
35
|
let effect;
|
|
36
|
+
await prefetchPayload(req.payload);
|
|
36
37
|
try {
|
|
37
38
|
const input = handler.input ? decodePayload(codecFor(handler.input), req.payload) : undefined;
|
|
38
39
|
effect = (await handler.run(agent, input));
|
|
@@ -10,7 +10,14 @@ export declare function refusal(spec: Spec, sidecarProtocol: string): string | u
|
|
|
10
10
|
export declare const STORAGE_SINCE: readonly [number, number];
|
|
11
11
|
/** Every problem the sidecar has reported to this process, in order. */
|
|
12
12
|
export declare const problems: string[];
|
|
13
|
-
|
|
13
|
+
/** What discovery needs beside the spec to judge personal data (1.23), and to start following its notices. */
|
|
14
|
+
export interface PersonalDiscovery {
|
|
15
|
+
/** The components and endpoints whose shapes hold a personal field. */
|
|
16
|
+
readonly users: () => readonly string[];
|
|
17
|
+
/** Told the runtime's protocol once discovery has been answered. */
|
|
18
|
+
readonly discovered?: (protocolVersion: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function discoveryRoutes(router: ConnectRouter, spec: () => Spec, log?: (message: string) => void, personal?: PersonalDiscovery): void;
|
|
14
21
|
/** The protocol version whose runtimes serve socket routes. */
|
|
15
22
|
export declare const SOCKETS_SINCE: readonly [number, number];
|
|
16
23
|
/** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.9
|
|
@@ -21,6 +28,11 @@ export declare const GRANTS_SINCE: readonly [number, number];
|
|
|
21
28
|
/** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.15
|
|
22
29
|
* does not know `Callers.granted`, and would admit nobody by it. */
|
|
23
30
|
export declare function grantsRefusal(spec: Spec, runtime: string): string | undefined;
|
|
31
|
+
/** The protocol version whose runtimes serve a subject's key, its notices and the erasure handler. */
|
|
32
|
+
export declare const PERSONAL_SINCE: readonly [number, number];
|
|
33
|
+
/** Why a service with personal fields (`users`) or an erasure handler cannot run on a runtime speaking
|
|
34
|
+
* `runtime`, or undefined. A runtime before 1.23 has no keyring to ask and would never call the handler. */
|
|
35
|
+
export declare function personalRefusal(spec: Spec, users: readonly string[], runtime: string): string | undefined;
|
|
24
36
|
/** The protocol version whose runtimes serve a process's MCP endpoint. */
|
|
25
37
|
export declare const MCP_SINCE: readonly [number, number];
|
|
26
38
|
/** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
|
package/dist/server/discovery.js
CHANGED
|
@@ -142,7 +142,7 @@ function startPositionRefusal(spec, sidecarProtocol) {
|
|
|
142
142
|
export const STORAGE_SINCE = [1, 21];
|
|
143
143
|
/** Every problem the sidecar has reported to this process, in order. */
|
|
144
144
|
export const problems = [];
|
|
145
|
-
export function discoveryRoutes(router, spec, log = console.error) {
|
|
145
|
+
export function discoveryRoutes(router, spec, log = console.error, personal) {
|
|
146
146
|
router.service(Discovery, {
|
|
147
147
|
async discover(info) {
|
|
148
148
|
log(`ankka: discovery from sidecar (protocol ${info.protocolVersion}, runtime ${info.runtimeVersion})`);
|
|
@@ -157,12 +157,14 @@ export function discoveryRoutes(router, spec, log = console.error) {
|
|
|
157
157
|
socketRefusal(answer, info.protocolVersion) ??
|
|
158
158
|
grantsRefusal(answer, info.protocolVersion) ??
|
|
159
159
|
// Last: an older runtime is told the oldest thing it lacks first.
|
|
160
|
-
mcpRefusal(answer, info.protocolVersion)
|
|
160
|
+
mcpRefusal(answer, info.protocolVersion) ??
|
|
161
|
+
personalRefusal(answer, personal?.users() ?? [], info.protocolVersion);
|
|
161
162
|
if (why !== undefined) {
|
|
162
163
|
log(`ankka: ${why}`);
|
|
163
164
|
problems.push(why);
|
|
164
165
|
throw new ConnectError(why, Code.FailedPrecondition);
|
|
165
166
|
}
|
|
167
|
+
personal?.discovered?.(info.protocolVersion);
|
|
166
168
|
return answer;
|
|
167
169
|
},
|
|
168
170
|
async reportError(problem) {
|
|
@@ -211,6 +213,16 @@ export function grantsRefusal(spec, runtime) {
|
|
|
211
213
|
return undefined;
|
|
212
214
|
return `this runtime speaks protocol ${runtime || "unknown"}; granted callers and another project's topics need 1.15 (${endpoints.join(", ")})`;
|
|
213
215
|
}
|
|
216
|
+
/** The protocol version whose runtimes serve a subject's key, its notices and the erasure handler. */
|
|
217
|
+
export const PERSONAL_SINCE = [1, 23];
|
|
218
|
+
/** Why a service with personal fields (`users`) or an erasure handler cannot run on a runtime speaking
|
|
219
|
+
* `runtime`, or undefined. A runtime before 1.23 has no keyring to ask and would never call the handler. */
|
|
220
|
+
export function personalRefusal(spec, users, runtime) {
|
|
221
|
+
const what = [...users, ...(spec.erasureHandler ? ["the erasure handler"] : [])];
|
|
222
|
+
if (what.length === 0 || !olderThan(runtime, PERSONAL_SINCE))
|
|
223
|
+
return undefined;
|
|
224
|
+
return `personal fields need protocol 1.23: this runtime speaks ${runtime || "unknown"}, and this SDK ${PROTOCOL_VERSION} (${what.join(", ")})`;
|
|
225
|
+
}
|
|
214
226
|
/** The protocol version whose runtimes serve a process's MCP endpoint. */
|
|
215
227
|
export const MCP_SINCE = [1, 20];
|
|
216
228
|
/** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ConnectRouter } from "@connectrpc/connect";
|
|
2
|
+
import { type ErasureCall, type ErasureReply } from "../_proto/ankka/protocol/v1/erasure_pb.ts";
|
|
3
|
+
import { type ErasureHandler } from "../erasure.ts";
|
|
4
|
+
import type { ServerContext } from "./server.ts";
|
|
5
|
+
/** Runs the service's handler for one call; the Connect servicer and the unit tests both call it. */
|
|
6
|
+
export declare function invokeErasure(handler: ErasureHandler | undefined, call: ErasureCall, ctx: ServerContext): Promise<ErasureReply>;
|
|
7
|
+
export declare function erasureRoutes(router: ConnectRouter, ctx: ServerContext): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The `Erasure` service (protocol 1.23, feature 042): the sidecar asks this process to run its erasure
|
|
2
|
+
// handler for one erased data subject. A handler that throws answers an `Error`, which the platform
|
|
3
|
+
// records as a failed run and runs again on the next pass; it is never a gRPC status.
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import { Erasure, ErasureReplySchema } from "../_proto/ankka/protocol/v1/erasure_pb.js";
|
|
6
|
+
import { secretsFor } from "../client.js";
|
|
7
|
+
import { metadataFromProto } from "../context.js";
|
|
8
|
+
import { CommandError, ErrorCode } from "../effects/common.js";
|
|
9
|
+
import { erasuresFor } from "../erasures.js";
|
|
10
|
+
import { finalityToProto } from "../erasure.js";
|
|
11
|
+
import { errorCodeToProto } from "../kinds.js";
|
|
12
|
+
import { subjectProblem } from "../personal.js";
|
|
13
|
+
import { processStorage } from "../storage.js";
|
|
14
|
+
function failed(message, code = ErrorCode.Internal) {
|
|
15
|
+
return create(ErasureReplySchema, { objectsErased: 0n, finality: "", error: { message, code: errorCodeToProto(code) } });
|
|
16
|
+
}
|
|
17
|
+
/** Runs the service's handler for one call; the Connect servicer and the unit tests both call it. */
|
|
18
|
+
export async function invokeErasure(handler, call, ctx) {
|
|
19
|
+
if (!handler)
|
|
20
|
+
return failed("this service declares no erasure handler", ErrorCode.NotFound);
|
|
21
|
+
const problem = subjectProblem(call.subject);
|
|
22
|
+
if (problem !== undefined)
|
|
23
|
+
return failed(problem, ErrorCode.BadRequest);
|
|
24
|
+
const metadata = metadataFromProto(call.metadata);
|
|
25
|
+
const client = ctx.client.withMetadata(metadata);
|
|
26
|
+
try {
|
|
27
|
+
const report = (await handler({ client, secrets: secretsFor(client), storage: ctx.storage ?? processStorage(), erasures: erasuresFor(client), erasureSeq: call.erasureSeq, rerun: call.rerun, metadata }, call.subject)) ?? {};
|
|
28
|
+
const count = report.objectsErased ?? 0;
|
|
29
|
+
return create(ErasureReplySchema, { objectsErased: BigInt(count), finality: finalityToProto(report.finality) });
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
ctx.log(`ankka: the erasure handler failed for '${call.subject}': ${e instanceof Error ? e.message : String(e)}`);
|
|
33
|
+
return failed(e instanceof Error ? e.message : String(e), e instanceof CommandError ? e.code : ErrorCode.Internal);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function erasureRoutes(router, ctx) {
|
|
37
|
+
router.service(Erasure, {
|
|
38
|
+
invoke: (call) => invokeErasure(ctx.registry.erasureHandler, call, ctx),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -14,7 +14,8 @@ import { ErrorCode } from "../effects/common.js";
|
|
|
14
14
|
import { binaryCodecs } from "../codec.js";
|
|
15
15
|
import { errorCodeToProto } from "../kinds.js";
|
|
16
16
|
import { materialiseEventSourced } from "../materialise.js";
|
|
17
|
-
import {
|
|
17
|
+
import { PersonalError } from "../personal.js";
|
|
18
|
+
import { decodePayload, encodePayload, prefetchPayload, prefetchPayloadAt, prefetchValue, prefetchValueAt } from "./payloads.js";
|
|
18
19
|
/** A replicated entity's handover or change to its replication set, as the reply carries it (1.19). */
|
|
19
20
|
export function replicationToProto(change) {
|
|
20
21
|
if (!change)
|
|
@@ -53,11 +54,16 @@ export async function* handleEventSourced(requests, ctx) {
|
|
|
53
54
|
let sequence = 0n;
|
|
54
55
|
// This installation, for a replicated entity (1.19): null on a service that names none.
|
|
55
56
|
let here = null;
|
|
57
|
+
// The entity's home as its records have named it so far (FR-032): Init.home, then each folded
|
|
58
|
+
// event's installation, then Command.home. Its personal fields' keys are that installation's.
|
|
59
|
+
let home = null;
|
|
60
|
+
const keysAt = () => (home === null || home === here ? "" : home);
|
|
56
61
|
for await (const m of requests) {
|
|
57
62
|
switch (m.message.case) {
|
|
58
63
|
case "init": {
|
|
59
64
|
const init = m.message.value;
|
|
60
65
|
here = init.installation || null;
|
|
66
|
+
home = init.home || null;
|
|
61
67
|
const found = ctx.registry.component(init.componentId);
|
|
62
68
|
if (!found || found.kind !== "event-sourced") {
|
|
63
69
|
yield failure(0n, { message: `no event sourced entity ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound });
|
|
@@ -65,11 +71,12 @@ export async function* handleEventSourced(requests, ctx) {
|
|
|
65
71
|
}
|
|
66
72
|
registered = found;
|
|
67
73
|
entityId = init.entityId;
|
|
74
|
+
await prefetchPayloadAt(keysAt(), init.snapshot?.payload);
|
|
68
75
|
try {
|
|
69
76
|
entity = new registered.cls();
|
|
70
77
|
entity._bindInstance(entityId);
|
|
71
78
|
if (init.snapshot) {
|
|
72
|
-
state = decodePayload(registered.stateCodec, init.snapshot.payload);
|
|
79
|
+
state = decodePayload(registered.stateCodec, init.snapshot.payload, keysAt());
|
|
73
80
|
sequence = init.snapshot.sequence;
|
|
74
81
|
}
|
|
75
82
|
else {
|
|
@@ -90,9 +97,13 @@ export async function* handleEventSourced(requests, ctx) {
|
|
|
90
97
|
return;
|
|
91
98
|
}
|
|
92
99
|
const ev = m.message.value;
|
|
100
|
+
// A record from before replication names no installation, and reads as written at the home.
|
|
101
|
+
if (ev.installation)
|
|
102
|
+
home = ev.installation;
|
|
103
|
+
await prefetchPayloadAt(keysAt(), ev.payload);
|
|
93
104
|
try {
|
|
94
105
|
const written = ev.installation || here;
|
|
95
|
-
state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload), eventContext(written, written === here, ev.sequence, ev.attribution, ev.forwardedFrom));
|
|
106
|
+
state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload, keysAt()), eventContext(written, written === here, ev.sequence, ev.attribution, ev.forwardedFrom));
|
|
96
107
|
sequence = ev.sequence;
|
|
97
108
|
}
|
|
98
109
|
catch (e) {
|
|
@@ -107,7 +118,9 @@ export async function* handleEventSourced(requests, ctx) {
|
|
|
107
118
|
yield failure(m.message.value.id, { message: "a command arrived before Init", code: ErrorCode.Internal });
|
|
108
119
|
return;
|
|
109
120
|
}
|
|
110
|
-
|
|
121
|
+
if (m.message.value.home)
|
|
122
|
+
home = m.message.value.home;
|
|
123
|
+
const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx, here, keysAt());
|
|
111
124
|
yield result.out;
|
|
112
125
|
if (result.newState !== undefined) {
|
|
113
126
|
state = result.newState.state;
|
|
@@ -120,11 +133,12 @@ export async function* handleEventSourced(requests, ctx) {
|
|
|
120
133
|
}
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
|
-
async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx, here = null) {
|
|
136
|
+
async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx, here = null, keys = "") {
|
|
124
137
|
const handler = registered.handlers.get(cmd.name);
|
|
125
138
|
if (!handler)
|
|
126
139
|
return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
|
|
127
140
|
let input;
|
|
141
|
+
await prefetchPayload(cmd.payload);
|
|
128
142
|
try {
|
|
129
143
|
input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
|
|
130
144
|
}
|
|
@@ -154,8 +168,11 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
|
|
|
154
168
|
applied += 1n;
|
|
155
169
|
return entity.applyEvent(s, e, eventContext(here, true, applied, context.attribution, context.forwardedFrom));
|
|
156
170
|
});
|
|
171
|
+
// What is persisted, and the snapshot, are the record's: sealed with its home's keys (FR-032).
|
|
172
|
+
await prefetchValueAt(keys, m.events, cmd.snapshotRequested ? m.newState : undefined);
|
|
173
|
+
await prefetchValue(m.reply);
|
|
157
174
|
const replication = !m.error && effect.kind === "persist" ? replicationToProto(effect.replication) : undefined;
|
|
158
|
-
const events = m.events.map((e) => encodePayload(registered.eventCodec, e));
|
|
175
|
+
const events = m.events.map((e) => encodePayload(registered.eventCodec, e, keys));
|
|
159
176
|
const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
|
|
160
177
|
const outcome = m.error
|
|
161
178
|
? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
|
|
@@ -170,7 +187,7 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
|
|
|
170
187
|
events,
|
|
171
188
|
retention: retentionToProto(m.retention),
|
|
172
189
|
outcome,
|
|
173
|
-
snapshot: cmd.snapshotRequested ? encodePayload(registered.stateCodec, m.newState) : undefined,
|
|
190
|
+
snapshot: cmd.snapshotRequested ? encodePayload(registered.stateCodec, m.newState, keys) : undefined,
|
|
174
191
|
replication,
|
|
175
192
|
},
|
|
176
193
|
},
|
|
@@ -181,6 +198,6 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
|
|
|
181
198
|
}
|
|
182
199
|
catch (e) {
|
|
183
200
|
ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
|
|
184
|
-
return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
|
|
201
|
+
return { out: failure(cmd.id, { message: messageOf(e), code: e instanceof PersonalError ? e.code : ErrorCode.Internal }) };
|
|
185
202
|
}
|
|
186
203
|
}
|
package/dist/server/keyValue.js
CHANGED
|
@@ -8,7 +8,8 @@ import { ErrorCode } from "../effects/common.js";
|
|
|
8
8
|
import { errorCodeToProto } from "../kinds.js";
|
|
9
9
|
import { materialiseKeyValue } from "../materialise.js";
|
|
10
10
|
import { retentionToProto } from "./eventSourced.js";
|
|
11
|
-
import {
|
|
11
|
+
import { PersonalError } from "../personal.js";
|
|
12
|
+
import { decodePayload, encodePayload, prefetchPayload, prefetchValue } from "./payloads.js";
|
|
12
13
|
function failure(commandId, error) {
|
|
13
14
|
return create(KeyValueOutSchema, { message: { case: "failure", value: { commandId, error: { message: error.message, code: errorCodeToProto(error.code) } } } });
|
|
14
15
|
}
|
|
@@ -36,6 +37,7 @@ export async function* handleKeyValue(requests, ctx) {
|
|
|
36
37
|
}
|
|
37
38
|
registered = found;
|
|
38
39
|
entityId = init.entityId;
|
|
40
|
+
await prefetchPayload(init.state);
|
|
39
41
|
try {
|
|
40
42
|
entity = new registered.cls();
|
|
41
43
|
entity._bindInstance(entityId);
|
|
@@ -69,6 +71,7 @@ async function runCommand(registered, entity, entityId, state, cmd, ctx) {
|
|
|
69
71
|
if (!handler)
|
|
70
72
|
return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
|
|
71
73
|
let input;
|
|
74
|
+
await prefetchPayload(cmd.payload);
|
|
72
75
|
try {
|
|
73
76
|
input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
|
|
74
77
|
}
|
|
@@ -94,6 +97,7 @@ async function runCommand(registered, entity, entityId, state, cmd, ctx) {
|
|
|
94
97
|
}
|
|
95
98
|
try {
|
|
96
99
|
const m = materialiseKeyValue(effect, state);
|
|
100
|
+
await prefetchValue(m.reply, m.changed ? m.newState : undefined);
|
|
97
101
|
const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
|
|
98
102
|
const outcome = m.error
|
|
99
103
|
? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
|
|
@@ -117,6 +121,6 @@ async function runCommand(registered, entity, entityId, state, cmd, ctx) {
|
|
|
117
121
|
}
|
|
118
122
|
catch (e) {
|
|
119
123
|
ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
|
|
120
|
-
return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
|
|
124
|
+
return { out: failure(cmd.id, { message: messageOf(e), code: e instanceof PersonalError ? e.code : ErrorCode.Internal }) };
|
|
121
125
|
}
|
|
122
126
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { type Payload } from "../_proto/ankka/protocol/v1/payload_pb.ts";
|
|
2
2
|
import type { Codec } from "../codec.ts";
|
|
3
|
-
export declare function encodePayload<T>(codec: Codec<T>, value: T): Payload;
|
|
3
|
+
export declare function encodePayload<T>(codec: Codec<T>, value: T, home?: string): Payload;
|
|
4
4
|
/** Decodes a payload with `codec`. An absent payload is the empty payload, which is what `unit` and `done` are. */
|
|
5
|
-
export declare function decodePayload<T>(codec: Codec<T>, payload: Payload | undefined): T;
|
|
5
|
+
export declare function decodePayload<T>(codec: Codec<T>, payload: Payload | undefined, home?: string): T;
|
|
6
|
+
/** Fetches the keys every personal envelope in these payloads needs. Never throws. */
|
|
7
|
+
export declare function prefetchPayload(...payloads: (Payload | undefined)[]): Promise<void>;
|
|
8
|
+
/** Fetches, from `home`'s keyring, the keys every personal envelope in these payloads needs. Never throws. */
|
|
9
|
+
export declare function prefetchPayloadAt(home: string, ...payloads: (Payload | undefined)[]): Promise<void>;
|
|
10
|
+
/** Fetches the keys every personal field in these values needs to be written. Never throws. */
|
|
11
|
+
export declare function prefetchValue(...values: unknown[]): Promise<void>;
|
|
12
|
+
/** Fetches, from `home`'s keyring, the keys every personal field in these values needs to be written. Never throws. */
|
|
13
|
+
export declare function prefetchValueAt(home: string, ...values: unknown[]): Promise<void>;
|
|
6
14
|
export declare const EMPTY_PAYLOAD: Payload;
|