ankka-console 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/client/control-plane.d.ts +19 -1
- package/dist/client/control-plane.js +38 -1
- package/dist/client/schemas.d.ts +216 -0
- package/dist/client/schemas.js +93 -0
- package/dist/extensions/types.d.ts +1 -1
- package/dist/extensions/types.js +5 -0
- package/dist/routes/erasure.certificate.d.ts +7 -0
- package/dist/routes/erasure.certificate.js +12 -0
- package/dist/routes/erasure.d.ts +53 -0
- package/dist/routes/erasure.js +74 -0
- package/dist/routes/project.d.ts +27 -1
- package/dist/routes/project.js +30 -5
- package/dist/routes.js +2 -0
- package/dist/styles.css +1 -1
- package/dist/testing/fake-control-plane.d.ts +50 -0
- package/dist/testing/fake-control-plane.js +220 -3
- package/dist/ui/erasures.d.ts +9 -0
- package/dist/ui/erasures.js +16 -0
- package/package.json +8 -8
|
@@ -96,6 +96,13 @@ function fakeFingerprint(schema) {
|
|
|
96
96
|
}
|
|
97
97
|
/** The hostings whose pods hold the platform's container beside the developer's. */
|
|
98
98
|
const twoContainers = (hosting) => hosting === "process" || hosting === "web";
|
|
99
|
+
/** A handler's answer with 201, as the control plane gives for a new erasure request. */
|
|
100
|
+
class Created {
|
|
101
|
+
body;
|
|
102
|
+
constructor(body) {
|
|
103
|
+
this.body = body;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
99
106
|
class HttpError extends Error {
|
|
100
107
|
status;
|
|
101
108
|
constructor(status, message) {
|
|
@@ -135,6 +142,8 @@ export async function fakeControlPlane(options = {}) {
|
|
|
135
142
|
let base = "";
|
|
136
143
|
let objectStore = "garage";
|
|
137
144
|
let cloud = null;
|
|
145
|
+
let erasureOn = false;
|
|
146
|
+
let erasureSeq = 0;
|
|
138
147
|
const serviceKey = (p, n) => `${p}/${n}`;
|
|
139
148
|
// ── Views of state ─────────────────────────────────────────────────────────
|
|
140
149
|
const orgSummary = (org, caller) => {
|
|
@@ -383,10 +392,11 @@ export async function fakeControlPlane(options = {}) {
|
|
|
383
392
|
...(cloud ? { cloud: { ...where, ...(kmsKey && owns ? { kmsKey } : {}) } } : {}),
|
|
384
393
|
// Feature 038: where secrets are kept, as an installation that has said nothing answers.
|
|
385
394
|
secrets: { backend: "postgres", recordRetention: "365d", auditLog: "unknown" },
|
|
386
|
-
// Spec 057: a one-node broker and no bound on retention, as the shipped settings say
|
|
387
|
-
// erasure, which the platform does not have yet.
|
|
395
|
+
// Spec 057: a one-node broker and no bound on retention, as the shipped settings say.
|
|
388
396
|
broker: { nodes: 1, defaultCopies: 1, minInSync: 1 },
|
|
389
397
|
retention: { unbounded: true },
|
|
398
|
+
// Feature 042: the installation's erasure, as `ANKKA_ERASURE` sets it, with the keyring's default size.
|
|
399
|
+
erasure: { enabled: erasureOn, keyringInstances: 2 },
|
|
390
400
|
// Feature 041: where the backups go, as an installation with Garage and no copy says it.
|
|
391
401
|
backups: {
|
|
392
402
|
backupTarget: "object-store",
|
|
@@ -1070,6 +1080,195 @@ export async function fakeControlPlane(options = {}) {
|
|
|
1070
1080
|
const display = (c) => c.name ?? c.email ?? c.subject;
|
|
1071
1081
|
const noSuchProject = (id) => new HttpError(404, `no such project '${id}'`);
|
|
1072
1082
|
/** A project's grants are read by its organization's members and written by its owners; an outsider is told there is no project. */
|
|
1083
|
+
// ── Erasure requests (feature 042) ─────────────────────────────────────────
|
|
1084
|
+
const Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1085
|
+
/** A request's id: a ULID, sortable by time, as the control plane makes one. */
|
|
1086
|
+
const erasureId = () => {
|
|
1087
|
+
let time = Date.now();
|
|
1088
|
+
let head = "";
|
|
1089
|
+
for (let i = 0; i < 10; i++) {
|
|
1090
|
+
head = Crockford[time % 32] + head;
|
|
1091
|
+
time = Math.floor(time / 32);
|
|
1092
|
+
}
|
|
1093
|
+
const tail = [...randomBytes(16)].map((b) => Crockford[b % 32]).join("");
|
|
1094
|
+
return head + tail;
|
|
1095
|
+
};
|
|
1096
|
+
const SubjectRule = "a data subject is 1 to 253 characters from letters, digits, '.', '_', '-', '/' and ':'";
|
|
1097
|
+
/** Every write is refused while the installation's erasure is off, in the control plane's words. */
|
|
1098
|
+
function requireErasure() {
|
|
1099
|
+
if (!erasureOn)
|
|
1100
|
+
throw new HttpError(409, "erasure is off on this installation (ANKKA_ERASURE)");
|
|
1101
|
+
}
|
|
1102
|
+
/** The key destroyed and every service of the project completed: what the platform does after the answer. */
|
|
1103
|
+
function applyErasure(r) {
|
|
1104
|
+
const at = now();
|
|
1105
|
+
r.state = "applied";
|
|
1106
|
+
r.erasureSeq = ++erasureSeq;
|
|
1107
|
+
r.destroyedAt = at;
|
|
1108
|
+
r.appliedAt = at;
|
|
1109
|
+
r.lastFailure = undefined;
|
|
1110
|
+
r.completions = [...services.values()]
|
|
1111
|
+
.filter((s) => s.projectId === r.project)
|
|
1112
|
+
.map((s) => ({ service: s.name, completedAt: at, rowsRedacted: 3, tokensDropped: 1, sessionsEnded: 1 }))
|
|
1113
|
+
.sort((a, b) => a.service.localeCompare(b.service));
|
|
1114
|
+
}
|
|
1115
|
+
/** A request answered as `applying` is applied by the next read, as the real one is moments later. */
|
|
1116
|
+
function settleErasures(project) {
|
|
1117
|
+
for (const r of project.erasures ?? [])
|
|
1118
|
+
if (r.state === "applying")
|
|
1119
|
+
applyErasure(r);
|
|
1120
|
+
}
|
|
1121
|
+
function requireErasure404(project, rid) {
|
|
1122
|
+
const r = project.erasures?.find((e) => e.id === rid);
|
|
1123
|
+
if (!r)
|
|
1124
|
+
throw new HttpError(404, `no such erasure request '${project.id}/${rid}'`);
|
|
1125
|
+
return r;
|
|
1126
|
+
}
|
|
1127
|
+
const notHeld = (r, what) => {
|
|
1128
|
+
if (r.state !== "held")
|
|
1129
|
+
throw new HttpError(409, `erasure request '${r.project}/${r.id}': it is ${r.state} and cannot be ${what}`);
|
|
1130
|
+
};
|
|
1131
|
+
const erasureView = (r) => ({
|
|
1132
|
+
id: r.id,
|
|
1133
|
+
project: r.project,
|
|
1134
|
+
subject: r.subject,
|
|
1135
|
+
state: r.state,
|
|
1136
|
+
askedBy: r.askedBy,
|
|
1137
|
+
askedAt: r.askedAt,
|
|
1138
|
+
notBefore: r.notBefore,
|
|
1139
|
+
reason: r.reason,
|
|
1140
|
+
correlation: r.correlation,
|
|
1141
|
+
lapsedAt: r.lapsedAt,
|
|
1142
|
+
holdOverride: r.holdOverride,
|
|
1143
|
+
erasureSeq: r.erasureSeq,
|
|
1144
|
+
destroyedAt: r.destroyedAt,
|
|
1145
|
+
completions: r.completions,
|
|
1146
|
+
finalAt: r.finalAt,
|
|
1147
|
+
lastFailure: r.lastFailure,
|
|
1148
|
+
});
|
|
1149
|
+
const erasureSummary = (r, withProject = false) => ({
|
|
1150
|
+
id: r.id,
|
|
1151
|
+
subject: r.subject,
|
|
1152
|
+
state: r.state,
|
|
1153
|
+
correlation: r.correlation,
|
|
1154
|
+
notBefore: r.notBefore,
|
|
1155
|
+
askedBy: r.askedBy,
|
|
1156
|
+
askedAt: r.askedAt,
|
|
1157
|
+
appliedAt: r.appliedAt,
|
|
1158
|
+
finalAt: r.finalAt,
|
|
1159
|
+
project: withProject ? r.project : undefined,
|
|
1160
|
+
});
|
|
1161
|
+
function askErasure(project, subject, notBefore, reason, correlation, askedBy) {
|
|
1162
|
+
if (typeof subject !== "string" || !/^[A-Za-z0-9._/:-]{1,253}$/.test(subject))
|
|
1163
|
+
throw new HttpError(400, `the value given is not a data subject: ${SubjectRule}`);
|
|
1164
|
+
if (reason !== undefined && reason.length > 500)
|
|
1165
|
+
throw new HttpError(400, "a reason is at most 500 characters");
|
|
1166
|
+
if (correlation !== undefined && !/^[A-Za-z0-9._/:-]{1,128}$/.test(correlation))
|
|
1167
|
+
throw new HttpError(400, "a correlation is 1 to 128 characters from letters, digits, '.', '_', '-', '/' and ':'");
|
|
1168
|
+
if (notBefore !== undefined && Number.isNaN(Date.parse(notBefore)))
|
|
1169
|
+
throw new HttpError(400, `'${notBefore}' is not an instant`);
|
|
1170
|
+
const askedAt = now();
|
|
1171
|
+
const held = notBefore !== undefined && Date.parse(notBefore) > Date.parse(askedAt);
|
|
1172
|
+
const r = { id: erasureId(), project: project.id, subject, state: held ? "held" : "applying", askedBy, askedAt, notBefore, reason, correlation, completions: [] };
|
|
1173
|
+
project.erasures ??= [];
|
|
1174
|
+
project.erasures.push(r);
|
|
1175
|
+
return r;
|
|
1176
|
+
}
|
|
1177
|
+
route("POST", "/projects/{projectId}/erasures", (c, p, body) => {
|
|
1178
|
+
const { project, org } = requireProject(c, p.projectId);
|
|
1179
|
+
requireWrite(org);
|
|
1180
|
+
requireErasure();
|
|
1181
|
+
const b = (body ?? {});
|
|
1182
|
+
settleErasures(project);
|
|
1183
|
+
// Asked again for a data subject already erased: that request, answered 200 rather than 201.
|
|
1184
|
+
const applied = project.erasures?.find((r) => r.subject === b.subject && r.state === "applied");
|
|
1185
|
+
if (applied)
|
|
1186
|
+
return erasureView(applied);
|
|
1187
|
+
return new Created(erasureView(askErasure(project, b.subject, b.notBefore ?? undefined, b.reason ?? undefined, b.correlation ?? undefined, c.subject)));
|
|
1188
|
+
});
|
|
1189
|
+
route("GET", "/projects/{projectId}/erasures", (c, p, _body, url) => {
|
|
1190
|
+
const { project } = requireProject(c, p.projectId);
|
|
1191
|
+
settleErasures(project);
|
|
1192
|
+
const subject = url.searchParams.get("subject");
|
|
1193
|
+
const state = url.searchParams.get("state");
|
|
1194
|
+
const correlation = url.searchParams.get("correlation");
|
|
1195
|
+
if (state !== null && !["held", "applying", "applied", "withdrawn"].includes(state))
|
|
1196
|
+
throw new HttpError(400, `state is one of held, applying, applied, withdrawn, not '${state}'`);
|
|
1197
|
+
return [...(project.erasures ?? [])]
|
|
1198
|
+
.reverse()
|
|
1199
|
+
.filter((r) => (subject === null || r.subject === subject) && (state === null || r.state === state) && (correlation === null || r.correlation === correlation))
|
|
1200
|
+
.map((r) => erasureSummary(r));
|
|
1201
|
+
});
|
|
1202
|
+
route("GET", "/erasures", (c, _p, _body, url) => {
|
|
1203
|
+
const correlation = url.searchParams.get("correlation");
|
|
1204
|
+
return [...projects.values()]
|
|
1205
|
+
.filter((p) => organizations.get(p.organizationId)?.members.has(c.subject))
|
|
1206
|
+
.flatMap((p) => (settleErasures(p), [...(p.erasures ?? [])].reverse()))
|
|
1207
|
+
.filter((r) => correlation === null || r.correlation === correlation)
|
|
1208
|
+
.map((r) => erasureSummary(r, true));
|
|
1209
|
+
});
|
|
1210
|
+
route("GET", "/projects/{projectId}/erasures/{rid}", (c, p) => {
|
|
1211
|
+
const { project } = requireProject(c, p.projectId);
|
|
1212
|
+
settleErasures(project);
|
|
1213
|
+
return erasureView(requireErasure404(project, p.rid));
|
|
1214
|
+
});
|
|
1215
|
+
route("PUT", "/projects/{projectId}/erasures/{rid}", (c, p, body) => {
|
|
1216
|
+
const { project, org } = requireProject(c, p.projectId);
|
|
1217
|
+
requireWrite(org);
|
|
1218
|
+
requireErasure();
|
|
1219
|
+
const r = requireErasure404(project, p.rid);
|
|
1220
|
+
notHeld(r, "replaced");
|
|
1221
|
+
const b = (body ?? {});
|
|
1222
|
+
if (b.reason && b.reason.length > 500)
|
|
1223
|
+
throw new HttpError(400, "a reason is at most 500 characters");
|
|
1224
|
+
r.notBefore = b.notBefore ?? undefined;
|
|
1225
|
+
r.reason = b.reason ?? undefined;
|
|
1226
|
+
return erasureView(r);
|
|
1227
|
+
});
|
|
1228
|
+
route("POST", "/projects/{projectId}/erasures/{rid}/withdraw", (c, p) => {
|
|
1229
|
+
const { project, org } = requireProject(c, p.projectId);
|
|
1230
|
+
requireWrite(org);
|
|
1231
|
+
requireErasure();
|
|
1232
|
+
const r = requireErasure404(project, p.rid);
|
|
1233
|
+
notHeld(r, "withdrawn");
|
|
1234
|
+
r.state = "withdrawn";
|
|
1235
|
+
return erasureView(r);
|
|
1236
|
+
});
|
|
1237
|
+
route("POST", "/projects/{projectId}/erasures/{rid}/override", (c, p, body) => {
|
|
1238
|
+
const { project } = requireProject(c, p.projectId);
|
|
1239
|
+
const org = requireOwner(c, project.organizationId);
|
|
1240
|
+
requireWrite(org);
|
|
1241
|
+
requireErasure();
|
|
1242
|
+
const r = requireErasure404(project, p.rid);
|
|
1243
|
+
notHeld(r, "overridden");
|
|
1244
|
+
const reason = String(body?.reason ?? "");
|
|
1245
|
+
if (reason.trim() === "")
|
|
1246
|
+
throw new HttpError(400, "an override needs a reason");
|
|
1247
|
+
if (reason.length > 500)
|
|
1248
|
+
throw new HttpError(400, "an override's reason is at most 500 characters");
|
|
1249
|
+
r.state = "applying";
|
|
1250
|
+
r.holdOverride = { owner: c.subject, reason, at: now() };
|
|
1251
|
+
return erasureView(r);
|
|
1252
|
+
});
|
|
1253
|
+
route("GET", "/projects/{projectId}/erasures/{rid}/certificate", (c, p) => {
|
|
1254
|
+
const { project } = requireProject(c, p.projectId);
|
|
1255
|
+
settleErasures(project);
|
|
1256
|
+
const r = requireErasure404(project, p.rid);
|
|
1257
|
+
if (r.state !== "applied")
|
|
1258
|
+
throw new HttpError(409, `erasure request '${r.id}' is ${r.state}; a certificate is issued once it is applied`);
|
|
1259
|
+
return {
|
|
1260
|
+
request: r.id,
|
|
1261
|
+
project: r.project,
|
|
1262
|
+
subject: r.subject,
|
|
1263
|
+
askedBy: r.askedBy,
|
|
1264
|
+
askedAt: r.askedAt,
|
|
1265
|
+
hold: r.notBefore ? { notBefore: r.notBefore, reason: r.reason, lapsedAt: r.lapsedAt } : undefined,
|
|
1266
|
+
holdOverride: r.holdOverride,
|
|
1267
|
+
keyDestroyedAt: r.destroyedAt,
|
|
1268
|
+
services: r.completions.map((x) => ({ service: x.service, completedAt: x.completedAt })),
|
|
1269
|
+
finalAt: r.finalAt,
|
|
1270
|
+
};
|
|
1271
|
+
});
|
|
1073
1272
|
function grantProject(c, id, owner) {
|
|
1074
1273
|
const project = projects.get(id);
|
|
1075
1274
|
if (!project)
|
|
@@ -1726,7 +1925,10 @@ export async function fakeControlPlane(options = {}) {
|
|
|
1726
1925
|
match.r.keys.forEach((k, i) => (params[k] = decodeURIComponent(match.m[i + 1])));
|
|
1727
1926
|
try {
|
|
1728
1927
|
const result = match.r.handler(caller, params, body, url);
|
|
1729
|
-
|
|
1928
|
+
if (result instanceof Created)
|
|
1929
|
+
send(res, 201, result.body);
|
|
1930
|
+
else
|
|
1931
|
+
send(res, 200, result);
|
|
1730
1932
|
}
|
|
1731
1933
|
catch (e) {
|
|
1732
1934
|
if (e instanceof HttpError)
|
|
@@ -1799,6 +2001,8 @@ export async function fakeControlPlane(options = {}) {
|
|
|
1799
2001
|
cloud = seed.cloud;
|
|
1800
2002
|
if (seed.held)
|
|
1801
2003
|
hold = { ...seed.held, releasedAt: undefined, releasedBy: undefined };
|
|
2004
|
+
if (seed.erasure !== undefined)
|
|
2005
|
+
erasureOn = seed.erasure;
|
|
1802
2006
|
for (const o of seed.organizations ?? []) {
|
|
1803
2007
|
const members = new Map();
|
|
1804
2008
|
for (const s of o.owners ?? [])
|
|
@@ -1900,6 +2104,19 @@ export async function fakeControlPlane(options = {}) {
|
|
|
1900
2104
|
if (g.state && g.state !== "pending" && g.state !== "accepted")
|
|
1901
2105
|
grant.ended = { by: g.by ?? "seed", at: grant.granted.at };
|
|
1902
2106
|
}
|
|
2107
|
+
for (const e of seed.erasures ?? []) {
|
|
2108
|
+
const project = projects.get(e.projectId);
|
|
2109
|
+
if (!project)
|
|
2110
|
+
throw new Error(`no project ${e.projectId}`);
|
|
2111
|
+
const notBefore = e.notBefore ?? (e.state === "held" ? new Date(Date.now() + 30 * 86400000).toISOString() : undefined);
|
|
2112
|
+
const r = askErasure(project, e.subject, notBefore, e.reason, e.correlation, e.askedBy ?? "seed");
|
|
2113
|
+
if (e.state === "applied")
|
|
2114
|
+
applyErasure(r);
|
|
2115
|
+
else if (e.state === "withdrawn")
|
|
2116
|
+
r.state = "withdrawn";
|
|
2117
|
+
else if (r.state !== "held")
|
|
2118
|
+
throw new Error(`a held erasure request needs a not-before date in the future, not ${notBefore}`);
|
|
2119
|
+
}
|
|
1903
2120
|
},
|
|
1904
2121
|
close: () => new Promise((resolve) => {
|
|
1905
2122
|
server.closeAllConnections();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** What the erasure pages share (feature 042). */
|
|
2
|
+
/**
|
|
3
|
+
* A not-before date as a date field gives it (`YYYY-MM-DD`), as the instant the control plane takes:
|
|
4
|
+
* the start of that day, UTC. Empty is no hold. A value that is not a date is passed on as it is, for
|
|
5
|
+
* the control plane to refuse in its own words.
|
|
6
|
+
*/
|
|
7
|
+
export declare function notBeforeOf(date: string): string | undefined;
|
|
8
|
+
/** The not-before date as a date field shows it: the instant's day, UTC. */
|
|
9
|
+
export declare function dateOf(instant: string | undefined): string | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** What the erasure pages share (feature 042). */
|
|
2
|
+
/**
|
|
3
|
+
* A not-before date as a date field gives it (`YYYY-MM-DD`), as the instant the control plane takes:
|
|
4
|
+
* the start of that day, UTC. Empty is no hold. A value that is not a date is passed on as it is, for
|
|
5
|
+
* the control plane to refuse in its own words.
|
|
6
|
+
*/
|
|
7
|
+
export function notBeforeOf(date) {
|
|
8
|
+
if (!date)
|
|
9
|
+
return undefined;
|
|
10
|
+
const at = new Date(`${date}T00:00:00Z`);
|
|
11
|
+
return Number.isNaN(at.getTime()) ? date : at.toISOString();
|
|
12
|
+
}
|
|
13
|
+
/** The not-before date as a date field shows it: the instant's day, UTC. */
|
|
14
|
+
export function dateOf(instant) {
|
|
15
|
+
return instant ? instant.slice(0, 10) : undefined;
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ankka-console",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "The ankka console as a package: the control plane client, sign-in and sessions, and the pages as routes a React Router host mounts.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"class-variance-authority": "^0.7.1",
|
|
54
54
|
"clsx": "^2.1.1",
|
|
55
|
-
"jose": "^6.2.
|
|
56
|
-
"lucide-react": "^1.
|
|
57
|
-
"openid-client": "^6.8.
|
|
55
|
+
"jose": "^6.2.15",
|
|
56
|
+
"lucide-react": "^1.67.0",
|
|
57
|
+
"openid-client": "^6.8.9",
|
|
58
58
|
"zod": "^4.6.7"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@react-router/dev": "^8.5.
|
|
61
|
+
"@react-router/dev": "^8.5.1",
|
|
62
62
|
"@react-router/node": "^8.4.0",
|
|
63
63
|
"react": "^19.2.7",
|
|
64
64
|
"react-dom": "^19.2.7",
|
|
@@ -71,10 +71,10 @@
|
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@fontsource-variable/inter": "^5.3.0",
|
|
74
|
-
"@react-router/dev": "^8.5.
|
|
74
|
+
"@react-router/dev": "^8.5.1",
|
|
75
75
|
"@react-router/node": "^8.4.0",
|
|
76
|
-
"@tailwindcss/cli": "^4.3.
|
|
77
|
-
"@types/node": "^26.6.
|
|
76
|
+
"@tailwindcss/cli": "^4.3.4",
|
|
77
|
+
"@types/node": "^26.6.6",
|
|
78
78
|
"@types/react": "^19.3.0",
|
|
79
79
|
"@types/react-dom": "^19.3.0",
|
|
80
80
|
"react": "^19.3.1",
|