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/storage.js
CHANGED
|
@@ -26,8 +26,35 @@ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
|
26
26
|
import { isAbsolute, join, resolve } from "node:path";
|
|
27
27
|
import { Readable } from "node:stream";
|
|
28
28
|
import { CommandError, ErrorCode } from "./effects/common.js";
|
|
29
|
+
import { subjectProblem } from "./personal.js";
|
|
29
30
|
import { Credentials, EMPTY_PAYLOAD, UNSIGNED_PAYLOAD, encode, hmac, presign, sha256Hex, sign, SignedRequest } from "./sigv4.js";
|
|
30
|
-
import { Instant } from "./time.js";
|
|
31
|
+
import { Duration, Instant } from "./time.js";
|
|
32
|
+
const IMMEDIATE = Object.freeze({ kind: "immediate" });
|
|
33
|
+
/**
|
|
34
|
+
* Where a service keeps the objects of `subject` (feature 042): `subjects/`, the data subject with every
|
|
35
|
+
* `/` written `%2F`, and `/`. A data subject cannot hold `%`, so no subject prefix holds another's:
|
|
36
|
+
* `player`'s is `subjects/player/` and `player/8c1f`'s `subjects/player%2F8c1f/`. A string that is no
|
|
37
|
+
* data subject is refused (`BAD_REQUEST`).
|
|
38
|
+
*/
|
|
39
|
+
export function subjectPrefix(subject) {
|
|
40
|
+
const p = subjectProblem(subject);
|
|
41
|
+
if (p !== undefined)
|
|
42
|
+
throw badRequest(p);
|
|
43
|
+
return "subjects/" + subject.replaceAll("/", "%2F") + "/";
|
|
44
|
+
}
|
|
45
|
+
/** `erase` for a store that keeps no versions: list the subject's prefix and delete each object. */
|
|
46
|
+
async function eraseListed(bucket, subject, finality = IMMEDIATE) {
|
|
47
|
+
const names = [];
|
|
48
|
+
for await (const e of bucket.list(subjectPrefix(subject)))
|
|
49
|
+
names.push(e.name);
|
|
50
|
+
for (const n of names)
|
|
51
|
+
await bucket.delete(n);
|
|
52
|
+
return Object.freeze({ count: names.length, finality });
|
|
53
|
+
}
|
|
54
|
+
/** A bucket made of its other calls, with `erase` by listing. */
|
|
55
|
+
function listedBucket(bucket) {
|
|
56
|
+
return Object.freeze({ ...bucket, erase: (subject) => eraseListed(bucket, subject) });
|
|
57
|
+
}
|
|
31
58
|
// ── The rules and the words ────────────────────────────────────────────────────
|
|
32
59
|
const utf8 = new TextEncoder();
|
|
33
60
|
function shown(text) {
|
|
@@ -107,6 +134,10 @@ export const ObjectRules = Object.freeze({
|
|
|
107
134
|
if (p !== undefined)
|
|
108
135
|
throw badRequest(p);
|
|
109
136
|
},
|
|
137
|
+
/** Where a service keeps the objects of `subject`: `subjectPrefix`. */
|
|
138
|
+
subjectPrefix(subject) {
|
|
139
|
+
return subjectPrefix(subject);
|
|
140
|
+
},
|
|
110
141
|
});
|
|
111
142
|
/** What the storage client says when it refuses: the runtime's words (`StorageRefusals` in the Scala SDK). */
|
|
112
143
|
export const StorageRefusals = Object.freeze({
|
|
@@ -321,7 +352,7 @@ export class InMemoryBuckets {
|
|
|
321
352
|
return m;
|
|
322
353
|
};
|
|
323
354
|
const publicBase = this.#publicBase;
|
|
324
|
-
return
|
|
355
|
+
return listedBucket({
|
|
325
356
|
name,
|
|
326
357
|
exposure,
|
|
327
358
|
async put(objectName, body, options) {
|
|
@@ -538,7 +569,7 @@ export class DirectoryBuckets {
|
|
|
538
569
|
}
|
|
539
570
|
};
|
|
540
571
|
const address = (objectName) => `${store.#addressBase().replace(/\/$/, "")}${STORAGE_PATH}/${segment}/${addressOf(objectName)}`;
|
|
541
|
-
return
|
|
572
|
+
return listedBucket({
|
|
542
573
|
name,
|
|
543
574
|
exposure,
|
|
544
575
|
async put(objectName, body, options) {
|
|
@@ -663,6 +694,16 @@ export const MAX_ADDRESS_SEGMENTS = 16;
|
|
|
663
694
|
export function addressOf(objectName) {
|
|
664
695
|
return encode(objectName, objectName.split("/").length > MAX_ADDRESS_SEGMENTS);
|
|
665
696
|
}
|
|
697
|
+
/** `ANKKA_S3_SOFT_DELETE_DAYS`: a whole number of days from 1, or unset. */
|
|
698
|
+
function softDeleteOf(env) {
|
|
699
|
+
const variable = PREFIX + "SOFT_DELETE_DAYS";
|
|
700
|
+
const text = env[variable]?.trim() ?? "";
|
|
701
|
+
if (text === "")
|
|
702
|
+
return undefined;
|
|
703
|
+
if (!/^[0-9]+$/.test(text) || Number(text) < 1)
|
|
704
|
+
throw new Error(`${variable} is '${text}'; it is a whole number of days from 1`);
|
|
705
|
+
return Duration.ofSeconds(Number(text) * 86_400);
|
|
706
|
+
}
|
|
666
707
|
const PREFIX = "ANKKA_S3_";
|
|
667
708
|
function nonEmpty(env, name) {
|
|
668
709
|
const v = env[name];
|
|
@@ -754,7 +795,8 @@ export function s3Settings(env = process.env, timeoutMs = 30_000) {
|
|
|
754
795
|
throw new Error(`bucket '${name}' is public and ${PREFIX}PUBLIC_URL_${suffix} is not set`);
|
|
755
796
|
named.push(Object.freeze({ name, exposure: word, target: Object.freeze({ ...unnamed, bucket }), publicUrl }));
|
|
756
797
|
}
|
|
757
|
-
|
|
798
|
+
const softDelete = softDeleteOf(env);
|
|
799
|
+
return Object.freeze({ unnamed: unnamed && unnamed.bucket !== "" ? unnamed : undefined, named: Object.freeze(named), timeoutMs, softDelete });
|
|
758
800
|
}
|
|
759
801
|
const httpAgent = new HttpAgent({ keepAlive: true });
|
|
760
802
|
const httpsAgent = new HttpsAgent({ keepAlive: true });
|
|
@@ -857,6 +899,30 @@ export function parseListing(xml) {
|
|
|
857
899
|
const truncated = element(xml, "IsTruncated")?.trim() === "true";
|
|
858
900
|
return { entries, next: truncated ? element(xml, "NextContinuationToken") : undefined };
|
|
859
901
|
}
|
|
902
|
+
/** A `ListObjectVersions` answer: its versions and delete markers, and where the next page starts while it is truncated. */
|
|
903
|
+
export function parseVersions(xml) {
|
|
904
|
+
const versions = [];
|
|
905
|
+
let generations = false;
|
|
906
|
+
for (const m of xml.matchAll(/<(Version|DeleteMarker)>([\s\S]*?)<\/\1>/g)) {
|
|
907
|
+
const block = m[2];
|
|
908
|
+
const key = element(block, "Key");
|
|
909
|
+
if (key === undefined)
|
|
910
|
+
throw new Error(`a version listing's ${m[1]} has no Key`);
|
|
911
|
+
const versionId = element(block, "VersionId");
|
|
912
|
+
const generation = versionId === undefined ? element(block, "Generation") : undefined;
|
|
913
|
+
if (generation !== undefined)
|
|
914
|
+
generations = true;
|
|
915
|
+
const id = (versionId ?? generation)?.trim() ?? "";
|
|
916
|
+
versions.push([key, id]);
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
versions,
|
|
920
|
+
truncated: element(xml, "IsTruncated")?.trim() === "true",
|
|
921
|
+
nextKey: element(xml, "NextKeyMarker"),
|
|
922
|
+
nextVersion: element(xml, "NextVersionIdMarker") ?? element(xml, "NextGenerationMarker"),
|
|
923
|
+
generations: generations || element(xml, "NextGenerationMarker") !== undefined,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
860
926
|
function lastModifiedOf(response) {
|
|
861
927
|
const v = response.headers["last-modified"];
|
|
862
928
|
const d = v ? new Date(v) : undefined;
|
|
@@ -870,7 +936,9 @@ export class S3Bucket {
|
|
|
870
936
|
#publicUrl;
|
|
871
937
|
#timeoutMs;
|
|
872
938
|
#endpoint;
|
|
873
|
-
|
|
939
|
+
#finality;
|
|
940
|
+
constructor(target, name, exposure, publicUrl, timeoutMs = 30_000, softDelete) {
|
|
941
|
+
this.#finality = softDelete === undefined ? IMMEDIATE : Object.freeze({ kind: "after", after: softDelete });
|
|
874
942
|
this.#target = target;
|
|
875
943
|
this.name = name;
|
|
876
944
|
this.exposure = exposure;
|
|
@@ -952,6 +1020,69 @@ export class S3Bucket {
|
|
|
952
1020
|
} while (token !== undefined);
|
|
953
1021
|
})();
|
|
954
1022
|
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Lists every version and delete marker under the subject's prefix (`ListObjectVersions`) and deletes
|
|
1025
|
+
* each by its version id; the count is the objects with a version. A store that lists no versions
|
|
1026
|
+
* (Garage answers `NotImplemented`) is listed plainly and each object deleted. The finality is
|
|
1027
|
+
* `ANKKA_S3_SOFT_DELETE_DAYS`'s, or immediate.
|
|
1028
|
+
*/
|
|
1029
|
+
async erase(subject) {
|
|
1030
|
+
const prefix = subjectPrefix(subject);
|
|
1031
|
+
const versions = await this.#versionsUnder(prefix);
|
|
1032
|
+
if (versions === undefined)
|
|
1033
|
+
return eraseListed(this, subject, this.#finality);
|
|
1034
|
+
for (const [key, version] of versions)
|
|
1035
|
+
await this.#deleteVersion(key, version);
|
|
1036
|
+
const objects = new Set(versions.filter(([, v]) => v !== "").map(([k]) => k));
|
|
1037
|
+
return Object.freeze({ count: objects.size, finality: this.#finality });
|
|
1038
|
+
}
|
|
1039
|
+
/** Every version and delete marker under `prefix`, or `undefined` when the store lists none. */
|
|
1040
|
+
async #versionsUnder(prefix) {
|
|
1041
|
+
const found = [];
|
|
1042
|
+
let keyMarker;
|
|
1043
|
+
let versionMarker;
|
|
1044
|
+
let generations = false;
|
|
1045
|
+
for (;;) {
|
|
1046
|
+
const query = [
|
|
1047
|
+
["prefix", prefix],
|
|
1048
|
+
["versions", ""],
|
|
1049
|
+
];
|
|
1050
|
+
if (keyMarker !== undefined)
|
|
1051
|
+
query.push(["key-marker", keyMarker]);
|
|
1052
|
+
// Google pages by generation and names the next one `generation-marker`.
|
|
1053
|
+
if (versionMarker !== undefined)
|
|
1054
|
+
query.push([generations ? "generation-marker" : "version-id-marker", versionMarker]);
|
|
1055
|
+
const { status, response } = await this.#send("GET", `/${this.#target.bucket}`, query);
|
|
1056
|
+
const text = await textOf(response, 64 << 20);
|
|
1057
|
+
if (status === 501 || text.includes("<Code>NotImplemented</Code>"))
|
|
1058
|
+
return undefined;
|
|
1059
|
+
if (status !== 200)
|
|
1060
|
+
throw this.#refused(status, text);
|
|
1061
|
+
let page;
|
|
1062
|
+
try {
|
|
1063
|
+
page = parseVersions(text);
|
|
1064
|
+
}
|
|
1065
|
+
catch (e) {
|
|
1066
|
+
throw StorageRefusals.unreadable(this.#endpoint, e instanceof Error ? e.message : String(e));
|
|
1067
|
+
}
|
|
1068
|
+
found.push(...page.versions);
|
|
1069
|
+
keyMarker = page.nextKey;
|
|
1070
|
+
versionMarker = page.nextVersion;
|
|
1071
|
+
generations = page.generations;
|
|
1072
|
+
if (!page.truncated)
|
|
1073
|
+
return found;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* One version, or a delete marker; `version` empty is the plain delete. The store's `null` version is
|
|
1078
|
+
* deleted as `versionId=null`: a plain delete would only add a marker above it.
|
|
1079
|
+
*/
|
|
1080
|
+
async #deleteVersion(objectName, version) {
|
|
1081
|
+
const { status, response } = await this.#send("DELETE", this.#objectPath(objectName), version === "" ? [] : [["versionId", version]]);
|
|
1082
|
+
const text = await textOf(response);
|
|
1083
|
+
if (status !== 200 && status !== 204 && status !== 404)
|
|
1084
|
+
throw this.#refused(status, text);
|
|
1085
|
+
}
|
|
955
1086
|
signedUrl(objectName, access, spanSeconds = ObjectRules.DEFAULT_SIGNED_URL_SECONDS) {
|
|
956
1087
|
ObjectRules.checkName(objectName);
|
|
957
1088
|
let base;
|
|
@@ -1005,8 +1136,8 @@ export class S3Buckets {
|
|
|
1005
1136
|
#named;
|
|
1006
1137
|
constructor(settings = s3Settings()) {
|
|
1007
1138
|
const unnamedExposure = settings.unnamed?.publicEndpoint ? "signed" : "private";
|
|
1008
|
-
this.#unnamed = settings.unnamed && new S3Bucket(settings.unnamed, undefined, unnamedExposure, undefined, settings.timeoutMs);
|
|
1009
|
-
this.#named = new Map(settings.named.map((n) => [n.name, new S3Bucket(n.target, n.name, n.exposure, n.publicUrl, settings.timeoutMs)]));
|
|
1139
|
+
this.#unnamed = settings.unnamed && new S3Bucket(settings.unnamed, undefined, unnamedExposure, undefined, settings.timeoutMs, settings.softDelete);
|
|
1140
|
+
this.#named = new Map(settings.named.map((n) => [n.name, new S3Bucket(n.target, n.name, n.exposure, n.publicUrl, settings.timeoutMs, settings.softDelete)]));
|
|
1010
1141
|
}
|
|
1011
1142
|
get default() {
|
|
1012
1143
|
if (!this.#unnamed)
|
package/dist/testkit/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised, type SocketRun, type ReplicationMode } from "./unit.ts";
|
|
2
2
|
export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, KeyedViewTestKit, ConsumerTestKit, GraphConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, type GraphChange, } from "./kinds.ts";
|
|
3
|
-
export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside } from "./integration.ts";
|
|
3
|
+
export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside, type KitKeyring } from "./integration.ts";
|
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
import type { ComponentClient } from "../client.ts";
|
|
2
2
|
import type { ServiceBuilder } from "../service.ts";
|
|
3
3
|
import { type Buckets } from "../storage.ts";
|
|
4
|
+
/**
|
|
5
|
+
* One statement that searches every column of every table of the service's database, cast to text,
|
|
6
|
+
* for each value as written, as base64 (standard and URL-safe, unpadded) and as lower-case hex in the
|
|
7
|
+
* lower-cased text: the rules of the JVM kit's `PersonalAssertions`, the same forms. Each row is
|
|
8
|
+
* `<index of the value> <form> <schema>.<table>.<column>`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function personalValueQuery(values: readonly string[]): string;
|
|
11
|
+
/** The JVM kit's failure message for what `personalValueQuery` found, or `undefined` for nothing. */
|
|
12
|
+
export declare function personalValueFailure(values: readonly string[], output: string): string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* The keyring of the kit's sidecar (feature 042): the runtime's local keyring, kept in the kit's
|
|
15
|
+
* database, reached through the sidecar's local console endpoint. `erase` destroys a data subject's key
|
|
16
|
+
* at once and sends the notice, as an applied erasure request does; `outage` makes the keyring refuse
|
|
17
|
+
* every request as unavailable, erasures included, until it ends or `recover` ends it. An outage is the
|
|
18
|
+
* sidecar's: `kit.restart()` ends it.
|
|
19
|
+
*/
|
|
20
|
+
export declare class KitKeyring {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(url: () => string);
|
|
23
|
+
/** Erases `subject` in the service's project now; the notice's sequence, once the sidecar has carried it out (or after ten seconds). Throws in an outage. */
|
|
24
|
+
erase(subject: string, rerun?: boolean): Promise<number>;
|
|
25
|
+
/** Refuses every request as unavailable for `seconds`: a subject whose key the sidecar holds is still read and written; one it does not hold is refused, `ErrorCode.Unavailable`. */
|
|
26
|
+
outage(seconds: number): Promise<void>;
|
|
27
|
+
/** Ends an outage now. */
|
|
28
|
+
recover(): Promise<void>;
|
|
29
|
+
}
|
|
4
30
|
export interface AnkkaTestKitOptions {
|
|
5
31
|
/** The sidecar image; `sidecarImage()` by default. */
|
|
6
32
|
readonly image?: string;
|
|
@@ -52,6 +78,8 @@ export declare const PUBLISHED_SIDECAR = "ghcr.io/thinkmorestupidless/ankka-side
|
|
|
52
78
|
export declare function sidecarImage(version?: string): string;
|
|
53
79
|
export declare class AnkkaTestKit {
|
|
54
80
|
#private;
|
|
81
|
+
/** The sidecar's keyring: erase a data subject, start or end an outage (feature 042). */
|
|
82
|
+
readonly keyring: KitKeyring;
|
|
55
83
|
/** The component client every handler in `service` uses, pointed at the sidecar's mapped callback port. */
|
|
56
84
|
readonly client: ComponentClient;
|
|
57
85
|
/** The buckets the service holds: what a handler kept, a test reads here, and what a test puts here a handler reads. */
|
|
@@ -64,6 +92,13 @@ export declare class AnkkaTestKit {
|
|
|
64
92
|
get baseUrl(): string;
|
|
65
93
|
/** The JDBC URL of the throwaway database. */
|
|
66
94
|
get jdbcUrl(): string;
|
|
95
|
+
/**
|
|
96
|
+
* Fails, naming each table and column, when any of `values` can be read back from the service's
|
|
97
|
+
* database as written, as base64 (standard and URL-safe) or as hex: what a personal field must never
|
|
98
|
+
* be (feature 042, FR-030). Every column of every table, cast to text, searched by the kit's Postgres
|
|
99
|
+
* itself with the JVM kit's rules (`personalValueQuery`).
|
|
100
|
+
*/
|
|
101
|
+
assertNoPersonalValue(...values: string[]): Promise<void>;
|
|
67
102
|
/** Replaces the sidecar against the same database: every instance is gone from memory, so the next read rebuilds from the journal. */
|
|
68
103
|
restart(): Promise<void>;
|
|
69
104
|
/** Starts another image against this kit's Postgres — the Scala cart, say, to prove the journal is shared. */
|
|
@@ -21,6 +21,82 @@ import { VERSION } from "../version.js";
|
|
|
21
21
|
const POSTGRES_IMAGE = "postgres:17-alpine";
|
|
22
22
|
const HTTP_PORT = 9000;
|
|
23
23
|
const CALLBACK_PORT = 9011;
|
|
24
|
+
// The sidecar's local console endpoint, where `kit.keyring` files erasures and outages: named with
|
|
25
|
+
// ANKKA_OBSERVABILITY_ADDRESS so Docker can publish it, where a service run on a machine binds it to
|
|
26
|
+
// loopback on an ephemeral port.
|
|
27
|
+
const LOCAL_ENDPOINT_PORT = 9012;
|
|
28
|
+
/**
|
|
29
|
+
* One statement that searches every column of every table of the service's database, cast to text,
|
|
30
|
+
* for each value as written, as base64 (standard and URL-safe, unpadded) and as lower-case hex in the
|
|
31
|
+
* lower-cased text: the rules of the JVM kit's `PersonalAssertions`, the same forms. Each row is
|
|
32
|
+
* `<index of the value> <form> <schema>.<table>.<column>`.
|
|
33
|
+
*/
|
|
34
|
+
export function personalValueQuery(values) {
|
|
35
|
+
const quote = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
36
|
+
const rows = values.flatMap((value, i) => {
|
|
37
|
+
const data = Buffer.from(value, "utf8");
|
|
38
|
+
const forms = {
|
|
39
|
+
plain: value,
|
|
40
|
+
base64: data.toString("base64").replace(/=+$/, ""),
|
|
41
|
+
base64url: data.toString("base64url"),
|
|
42
|
+
hex: data.toString("hex"),
|
|
43
|
+
};
|
|
44
|
+
return Object.entries(forms).map(([name, form]) => `(${i}, ${quote(name)}, ${quote(form)})`);
|
|
45
|
+
});
|
|
46
|
+
return ("SELECT f.i || ' ' || f.form_name || ' ' || c.table_schema || '.' || c.table_name || '.' || c.column_name " +
|
|
47
|
+
`FROM information_schema.columns c CROSS JOIN (VALUES ${rows.join(", ")}) AS f(i, form_name, form) ` +
|
|
48
|
+
"WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') " +
|
|
49
|
+
"AND (xpath('/row/n/text()', query_to_xml(format('SELECT count(*) AS n FROM %I.%I WHERE strpos(%s, %L) > 0', " +
|
|
50
|
+
"c.table_schema, c.table_name, " +
|
|
51
|
+
"CASE WHEN f.form_name = 'hex' THEN format('lower(%I::text)', c.column_name) ELSE format('%I::text', c.column_name) END, " +
|
|
52
|
+
"f.form), false, true, '')))[1]::text::int > 0 " +
|
|
53
|
+
"ORDER BY c.table_schema, c.table_name, c.ordinal_position, f.i");
|
|
54
|
+
}
|
|
55
|
+
/** The JVM kit's failure message for what `personalValueQuery` found, or `undefined` for nothing. */
|
|
56
|
+
export function personalValueFailure(values, output) {
|
|
57
|
+
const found = output
|
|
58
|
+
.split("\n")
|
|
59
|
+
.filter((line) => line.trim() !== "")
|
|
60
|
+
.map((line) => {
|
|
61
|
+
const [i, form, ...where] = line.split(" ");
|
|
62
|
+
return ` '${values[Number(i)]}' as ${form} in ${where.join(" ")}`;
|
|
63
|
+
});
|
|
64
|
+
return found.length === 0 ? undefined : `a personal value can be read back from what the service kept:\n${found.join("\n")}`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The keyring of the kit's sidecar (feature 042): the runtime's local keyring, kept in the kit's
|
|
68
|
+
* database, reached through the sidecar's local console endpoint. `erase` destroys a data subject's key
|
|
69
|
+
* at once and sends the notice, as an applied erasure request does; `outage` makes the keyring refuse
|
|
70
|
+
* every request as unavailable, erasures included, until it ends or `recover` ends it. An outage is the
|
|
71
|
+
* sidecar's: `kit.restart()` ends it.
|
|
72
|
+
*/
|
|
73
|
+
export class KitKeyring {
|
|
74
|
+
#url;
|
|
75
|
+
constructor(url) {
|
|
76
|
+
this.#url = url;
|
|
77
|
+
}
|
|
78
|
+
async #post(path, body) {
|
|
79
|
+
const r = await fetch(this.#url() + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
80
|
+
const text = await r.text();
|
|
81
|
+
if (r.status !== 200)
|
|
82
|
+
throw new Error(`${path} answered ${r.status}: ${text}`);
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
/** Erases `subject` in the service's project now; the notice's sequence, once the sidecar has carried it out (or after ten seconds). Throws in an outage. */
|
|
86
|
+
async erase(subject, rerun = false) {
|
|
87
|
+
return Number((await this.#post("/_ankka/erasures", { subject, rerun })).seq);
|
|
88
|
+
}
|
|
89
|
+
/** Refuses every request as unavailable for `seconds`: a subject whose key the sidecar holds is still read and written; one it does not hold is refused, `ErrorCode.Unavailable`. */
|
|
90
|
+
async outage(seconds) {
|
|
91
|
+
if (!(seconds > 0))
|
|
92
|
+
throw new Error("an outage lasts more than 0 seconds; recover() ends one");
|
|
93
|
+
await this.#post("/_ankka/keyring/outage", { seconds });
|
|
94
|
+
}
|
|
95
|
+
/** Ends an outage now. */
|
|
96
|
+
async recover() {
|
|
97
|
+
await this.#post("/_ankka/keyring/outage", { seconds: 0 });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
24
100
|
/** What `http` answers with. */
|
|
25
101
|
export class HttpResponse {
|
|
26
102
|
status;
|
|
@@ -148,6 +224,13 @@ export class AnkkaTestKit {
|
|
|
148
224
|
#server;
|
|
149
225
|
#processPort = 0;
|
|
150
226
|
#http;
|
|
227
|
+
#localUrl;
|
|
228
|
+
/** The sidecar's keyring: erase a data subject, start or end an outage (feature 042). */
|
|
229
|
+
keyring = new KitKeyring(() => {
|
|
230
|
+
if (!this.#localUrl)
|
|
231
|
+
throw new Error("the testkit is not started");
|
|
232
|
+
return this.#localUrl;
|
|
233
|
+
});
|
|
151
234
|
/** The component client every handler in `service` uses, pointed at the sidecar's mapped callback port. */
|
|
152
235
|
client;
|
|
153
236
|
/** The buckets the service holds: what a handler kept, a test reads here, and what a test puts here a handler reads. */
|
|
@@ -228,9 +311,10 @@ export class AnkkaTestKit {
|
|
|
228
311
|
// A fresh secret key per kit, so the secret store works with no setup; the caller's `env`
|
|
229
312
|
// replaces it, and `ANKKA_SECRET_KEY: ""` starts a service with none.
|
|
230
313
|
ANKKA_SECRET_KEY: randomBytes(32).toString("base64"),
|
|
314
|
+
ANKKA_OBSERVABILITY_ADDRESS: `0.0.0.0:${LOCAL_ENDPOINT_PORT}`,
|
|
231
315
|
...this.#env,
|
|
232
316
|
})
|
|
233
|
-
.withExposedPorts(HTTP_PORT, CALLBACK_PORT)
|
|
317
|
+
.withExposedPorts(HTTP_PORT, CALLBACK_PORT, LOCAL_ENDPOINT_PORT)
|
|
234
318
|
// Both ports, not only the health route: it answers as soon as HTTP is bound, which can be
|
|
235
319
|
// before the callback server listens, and Docker's port proxy accepts then resets a
|
|
236
320
|
// connection to a port nothing in the container listens on yet — so a handler's first call
|
|
@@ -241,6 +325,27 @@ export class AnkkaTestKit {
|
|
|
241
325
|
this.#sidecar = container;
|
|
242
326
|
this.client.reconnect(`${container.getHost()}:${container.getMappedPort(CALLBACK_PORT)}`);
|
|
243
327
|
this.#http = new Http(`http://${container.getHost()}:${container.getMappedPort(HTTP_PORT)}`);
|
|
328
|
+
this.#localUrl = `http://${container.getHost()}:${container.getMappedPort(LOCAL_ENDPOINT_PORT)}`;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Fails, naming each table and column, when any of `values` can be read back from the service's
|
|
332
|
+
* database as written, as base64 (standard and URL-safe) or as hex: what a personal field must never
|
|
333
|
+
* be (feature 042, FR-030). Every column of every table, cast to text, searched by the kit's Postgres
|
|
334
|
+
* itself with the JVM kit's rules (`personalValueQuery`).
|
|
335
|
+
*/
|
|
336
|
+
async assertNoPersonalValue(...values) {
|
|
337
|
+
if (values.length === 0)
|
|
338
|
+
return;
|
|
339
|
+
if (!this.#postgres)
|
|
340
|
+
throw new Error("the testkit is not started");
|
|
341
|
+
const r = await this.#postgres.exec(["psql", "-U", "ankka", "-d", "ankka", "-v", "ON_ERROR_STOP=1", "-At", "-c", personalValueQuery(values)]);
|
|
342
|
+
if (r.exitCode !== 0)
|
|
343
|
+
throw new Error(`the database could not be searched: ${r.output}`);
|
|
344
|
+
const failure = personalValueFailure(values, r.stdout ?? r.output);
|
|
345
|
+
if (failure !== undefined) {
|
|
346
|
+
const { AssertionError } = await import("node:assert");
|
|
347
|
+
throw new AssertionError({ message: failure });
|
|
348
|
+
}
|
|
244
349
|
}
|
|
245
350
|
/** Replaces the sidecar against the same database: every instance is gone from memory, so the next read rebuilds from the journal. */
|
|
246
351
|
async restart() {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.16.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Written by scripts/proto.ts from package.json; do not edit. 0.0.0 in the tree, the tag's version in a release.
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.16.0";
|
package/package.json
CHANGED