@cosmicdrift/kumiko-framework 0.165.2 → 0.165.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/__tests__/sse-broker.test.ts +27 -18
- package/src/api/sse-broker.ts +8 -8
- package/src/crypto/index.ts +1 -0
- package/src/crypto/subject-resolver.ts +15 -0
- package/src/db/__tests__/migrate-runner.test.ts +4 -0
- package/src/db/migrate-runner.ts +15 -3
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +24 -6
- package/src/engine/boot-validator/pii-retention.ts +18 -8
- package/src/engine/feature-changelog.ts +93 -0
- package/src/engine/feature-manifest.ts +4 -0
- package/src/engine/index.ts +10 -0
- package/src/jobs/__tests__/scheduler-id.test.ts +18 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-runner.ts +31 -1
- package/src/pipeline/dispatch-stream.ts +6 -3
- package/src/pipeline/system-hooks.ts +54 -7
- package/src/schema-cli.ts +2 -3
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +164 -0
- package/src/search/index.ts +1 -0
- package/src/search/purge-subject.ts +135 -0
- package/src/testing/__tests__/wait-for.test.ts +8 -4
- package/src/testing/wait-for.ts +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.165.
|
|
3
|
+
"version": "0.165.4",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -197,10 +197,10 @@
|
|
|
197
197
|
"zod": "^4.4.3"
|
|
198
198
|
},
|
|
199
199
|
"peerDependencies": {
|
|
200
|
-
"@cosmicdrift/kumiko-types": "^0.165.
|
|
200
|
+
"@cosmicdrift/kumiko-types": "^0.165.4"
|
|
201
201
|
},
|
|
202
202
|
"devDependencies": {
|
|
203
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.165.
|
|
203
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.165.4",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"pino-pretty": "^13.1.3"
|
|
206
206
|
},
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { createSseBroker, type SseEvent } from "../sse-broker";
|
|
2
|
+
import { createSseBroker, type SseBroker, type SseEvent } from "../sse-broker";
|
|
3
|
+
|
|
4
|
+
function requireAccessInvalidation(broker: SseBroker) {
|
|
5
|
+
const subscribe = broker.subscribeAccessInvalidation;
|
|
6
|
+
const publish = broker.publishAccessInvalidation;
|
|
7
|
+
if (!subscribe || !publish) {
|
|
8
|
+
throw new Error("createSseBroker must implement access-invalidation hooks");
|
|
9
|
+
}
|
|
10
|
+
return { subscribe, publish };
|
|
11
|
+
}
|
|
3
12
|
|
|
4
13
|
describe("SSE broker", () => {
|
|
5
14
|
test("adds client and tracks count", () => {
|
|
@@ -57,59 +66,59 @@ describe("SSE broker", () => {
|
|
|
57
66
|
});
|
|
58
67
|
|
|
59
68
|
test("subscribeAccessInvalidation fires only listeners on the same user's channel", () => {
|
|
60
|
-
const
|
|
69
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
61
70
|
const onInvalidateA = mock();
|
|
62
71
|
const onInvalidateB = mock();
|
|
63
72
|
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
subscribe("user-a", onInvalidateA);
|
|
74
|
+
subscribe("user-b", onInvalidateB);
|
|
66
75
|
|
|
67
|
-
|
|
76
|
+
publish("user-a");
|
|
68
77
|
|
|
69
78
|
expect(onInvalidateA).toHaveBeenCalledTimes(1);
|
|
70
79
|
expect(onInvalidateB).not.toHaveBeenCalled();
|
|
71
80
|
});
|
|
72
81
|
|
|
73
82
|
test("subscribeAccessInvalidation supports multiple listeners on the same user", () => {
|
|
74
|
-
const
|
|
83
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
75
84
|
const first = mock();
|
|
76
85
|
const second = mock();
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
subscribe("user-a", first);
|
|
88
|
+
subscribe("user-a", second);
|
|
89
|
+
publish("user-a");
|
|
81
90
|
|
|
82
91
|
expect(first).toHaveBeenCalledTimes(1);
|
|
83
92
|
expect(second).toHaveBeenCalledTimes(1);
|
|
84
93
|
});
|
|
85
94
|
|
|
86
95
|
test("unsubscribe (returned closure) stops further delivery", () => {
|
|
87
|
-
const
|
|
96
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
88
97
|
const onInvalidate = mock();
|
|
89
98
|
|
|
90
|
-
const unsubscribe =
|
|
99
|
+
const unsubscribe = subscribe("user-a", onInvalidate);
|
|
91
100
|
unsubscribe();
|
|
92
|
-
|
|
101
|
+
publish("user-a");
|
|
93
102
|
|
|
94
103
|
expect(onInvalidate).not.toHaveBeenCalled();
|
|
95
104
|
});
|
|
96
105
|
|
|
97
106
|
test("a fired listener can unsubscribe itself without skipping other listeners", () => {
|
|
98
|
-
const
|
|
107
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
99
108
|
let unsubscribeSelf: () => void = () => {};
|
|
100
109
|
const self = mock(() => unsubscribeSelf());
|
|
101
110
|
const other = mock();
|
|
102
111
|
|
|
103
|
-
unsubscribeSelf =
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
unsubscribeSelf = subscribe("user-a", self);
|
|
113
|
+
subscribe("user-a", other);
|
|
114
|
+
publish("user-a");
|
|
106
115
|
|
|
107
116
|
expect(self).toHaveBeenCalledTimes(1);
|
|
108
117
|
expect(other).toHaveBeenCalledTimes(1);
|
|
109
118
|
});
|
|
110
119
|
|
|
111
120
|
test("publishAccessInvalidation to a user with no listeners does nothing", () => {
|
|
112
|
-
const
|
|
113
|
-
expect(() =>
|
|
121
|
+
const { publish } = requireAccessInvalidation(createSseBroker());
|
|
122
|
+
expect(() => publish("nobody-listening")).not.toThrow();
|
|
114
123
|
});
|
|
115
124
|
});
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -19,8 +19,9 @@ export type SseBroker = {
|
|
|
19
19
|
getClientCount(channel: string): number;
|
|
20
20
|
getTotalClientCount(): number;
|
|
21
21
|
// Separate from addClient so it doesn't count towards getClientCount.
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Optional: additive for external/test SseBroker impls (call sites use?.).
|
|
23
|
+
subscribeAccessInvalidation?(userId: string, onInvalidate: () => void): () => void;
|
|
24
|
+
publishAccessInvalidation?(userId: string): void;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export function createSseBroker(): SseBroker {
|
|
@@ -28,7 +29,7 @@ export function createSseBroker(): SseBroker {
|
|
|
28
29
|
// Redis. Multi-replica deployments will not revoke SSE streams on other pods
|
|
29
30
|
// (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
|
|
30
31
|
const channels = new Map<string, Map<string, SseClient>>();
|
|
31
|
-
const accessInvalidationListeners = new Map<string,
|
|
32
|
+
const accessInvalidationListeners = new Map<string, Set<() => void>>();
|
|
32
33
|
|
|
33
34
|
function getOrCreateChannel(channel: string): Map<string, SseClient> {
|
|
34
35
|
let clients = channels.get(channel);
|
|
@@ -78,18 +79,17 @@ export function createSseBroker(): SseBroker {
|
|
|
78
79
|
|
|
79
80
|
subscribeAccessInvalidation(userId, onInvalidate) {
|
|
80
81
|
const channel = userAccessChannel(userId);
|
|
81
|
-
const listenerId = generateId();
|
|
82
82
|
let listeners = accessInvalidationListeners.get(channel);
|
|
83
83
|
if (!listeners) {
|
|
84
|
-
listeners = new
|
|
84
|
+
listeners = new Set();
|
|
85
85
|
accessInvalidationListeners.set(channel, listeners);
|
|
86
86
|
}
|
|
87
|
-
listeners.
|
|
87
|
+
listeners.add(onInvalidate);
|
|
88
88
|
return () => {
|
|
89
89
|
const current = accessInvalidationListeners.get(channel);
|
|
90
90
|
// skip: already unsubscribed (e.g. stream ended after a publish already fired)
|
|
91
91
|
if (!current) return;
|
|
92
|
-
current.delete(
|
|
92
|
+
current.delete(onInvalidate);
|
|
93
93
|
if (current.size === 0) accessInvalidationListeners.delete(channel);
|
|
94
94
|
};
|
|
95
95
|
},
|
|
@@ -101,7 +101,7 @@ export function createSseBroker(): SseBroker {
|
|
|
101
101
|
if (!listeners) return;
|
|
102
102
|
// Snapshot before iterating — a fired listener unsubscribes itself,
|
|
103
103
|
// which would mutate `listeners` mid-iteration otherwise.
|
|
104
|
-
for (const onInvalidate of [...listeners
|
|
104
|
+
for (const onInvalidate of [...listeners]) {
|
|
105
105
|
onInvalidate();
|
|
106
106
|
}
|
|
107
107
|
},
|
package/src/crypto/index.ts
CHANGED
|
@@ -89,3 +89,18 @@ export function collectPiiSubjectFields(entity: EntityDefinition): readonly stri
|
|
|
89
89
|
)
|
|
90
90
|
.map(([name]) => name);
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
|
|
94
|
+
export function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[] {
|
|
95
|
+
return Object.entries(entity.fields)
|
|
96
|
+
.filter(([, field]) => {
|
|
97
|
+
const subject =
|
|
98
|
+
("userOwned" in field && field.userOwned !== undefined) ||
|
|
99
|
+
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
100
|
+
("pii" in field && field.pii === true);
|
|
101
|
+
if (!subject) return false;
|
|
102
|
+
if ("sensitive" in field && field.sensitive === true) return false;
|
|
103
|
+
return "searchable" in field && field.searchable === true;
|
|
104
|
+
})
|
|
105
|
+
.map(([name]) => name);
|
|
106
|
+
}
|
|
@@ -38,6 +38,10 @@ describe("splitSqlStatements", () => {
|
|
|
38
38
|
expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test("nested block comments close only at matching depth (Postgres)", () => {
|
|
42
|
+
expect(splitSqlStatements("/* a /* b */ c */ SELECT 1;")).toEqual(["SELECT 1;"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
41
45
|
test("a block-comment opener inside a line comment does not swallow the next statement", () => {
|
|
42
46
|
const sql = `
|
|
43
47
|
-- note: see /* details below
|
package/src/db/migrate-runner.ts
CHANGED
|
@@ -81,6 +81,7 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
81
81
|
const statements: string[] = [];
|
|
82
82
|
let current = "";
|
|
83
83
|
let state: SqlScanState = "normal";
|
|
84
|
+
let blockCommentDepth = 0;
|
|
84
85
|
|
|
85
86
|
for (let i = 0; i < sqlText.length; i++) {
|
|
86
87
|
const ch = sqlText.charAt(i);
|
|
@@ -94,11 +95,21 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
94
95
|
continue;
|
|
95
96
|
}
|
|
96
97
|
if (state === "blockComment") {
|
|
98
|
+
// Postgres nests block comments — track depth so the first `*/` does
|
|
99
|
+
// not leave trailing comment text in the statement.
|
|
100
|
+
if (ch === "/" && next === "*") {
|
|
101
|
+
blockCommentDepth++;
|
|
102
|
+
i++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
97
105
|
if (ch === "*" && next === "/") {
|
|
98
|
-
state = "normal";
|
|
99
106
|
i++;
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
blockCommentDepth--;
|
|
108
|
+
if (blockCommentDepth === 0) {
|
|
109
|
+
state = "normal";
|
|
110
|
+
// Keep a space so `a/*x*/AS` does not become `aAS`.
|
|
111
|
+
current += " ";
|
|
112
|
+
}
|
|
102
113
|
}
|
|
103
114
|
continue;
|
|
104
115
|
}
|
|
@@ -135,6 +146,7 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
135
146
|
}
|
|
136
147
|
if (ch === "/" && next === "*") {
|
|
137
148
|
state = "blockComment";
|
|
149
|
+
blockCommentDepth = 1;
|
|
138
150
|
i++;
|
|
139
151
|
continue;
|
|
140
152
|
}
|
|
@@ -680,7 +680,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
680
680
|
expect(() => validateBoot([feature])).toThrow(/only apply to text fields/);
|
|
681
681
|
});
|
|
682
682
|
|
|
683
|
-
test("searchable combined with a subject annotation
|
|
683
|
+
test("searchable combined with a subject annotation passes (#1610)", () => {
|
|
684
684
|
const feature = defineFeature("test", (r) => {
|
|
685
685
|
r.entity(
|
|
686
686
|
"user",
|
|
@@ -691,7 +691,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
691
691
|
}),
|
|
692
692
|
);
|
|
693
693
|
});
|
|
694
|
-
expect(() => validateBoot([feature])).toThrow(
|
|
694
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
695
695
|
});
|
|
696
696
|
|
|
697
697
|
test("sortable combined with a subject annotation throws", () => {
|
|
@@ -705,7 +705,25 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
705
705
|
}),
|
|
706
706
|
);
|
|
707
707
|
});
|
|
708
|
-
expect(() => validateBoot([feature])).toThrow(/sortable
|
|
708
|
+
expect(() => validateBoot([feature])).toThrow(/sortable/);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("searchable combined with sensitive throws (#1610)", () => {
|
|
712
|
+
const feature = defineFeature("test", (r) => {
|
|
713
|
+
r.entity(
|
|
714
|
+
"user",
|
|
715
|
+
createEntity({
|
|
716
|
+
fields: {
|
|
717
|
+
passwordHash: createTextField({
|
|
718
|
+
pii: true,
|
|
719
|
+
sensitive: true,
|
|
720
|
+
searchable: true,
|
|
721
|
+
}),
|
|
722
|
+
},
|
|
723
|
+
}),
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
expect(() => validateBoot([feature])).toThrow(/sensitive.*searchable/);
|
|
709
727
|
});
|
|
710
728
|
});
|
|
711
729
|
|
|
@@ -758,7 +776,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
|
758
776
|
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without a subject annotation/);
|
|
759
777
|
});
|
|
760
778
|
|
|
761
|
-
test("piiEncrypted combined with searchable
|
|
779
|
+
test("piiEncrypted combined with searchable passes (#1610)", () => {
|
|
762
780
|
const feature = defineFeature("test", (r) => {
|
|
763
781
|
r.entity(
|
|
764
782
|
"tenant",
|
|
@@ -774,7 +792,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
|
774
792
|
}),
|
|
775
793
|
);
|
|
776
794
|
});
|
|
777
|
-
expect(() => validateBoot([feature])).toThrow(
|
|
795
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
778
796
|
});
|
|
779
797
|
|
|
780
798
|
test("piiEncrypted combined with sortable throws", () => {
|
|
@@ -793,7 +811,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
|
793
811
|
}),
|
|
794
812
|
);
|
|
795
813
|
});
|
|
796
|
-
expect(() => validateBoot([feature])).toThrow(/
|
|
814
|
+
expect(() => validateBoot([feature])).toThrow(/sortable/);
|
|
797
815
|
});
|
|
798
816
|
|
|
799
817
|
test("piiEncrypted without access.read throws (kumiko-platform#460)", () => {
|
|
@@ -129,15 +129,25 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
// Sortierung liest die Projection-Spalte — die bleibt Ciphertext, also
|
|
133
|
+
// sortable + Subject-Annotation bleibt Boot-Fail. searchable ist seit
|
|
134
|
+
// #1610 erlaubt: der Search-Consumer decryptet in den abgeleiteten
|
|
135
|
+
// Index und forget purgt die Docs (siehe createSearchEventConsumer).
|
|
136
|
+
// sensitive + searchable bleibt verboten (nobody-may-read-back).
|
|
137
|
+
{
|
|
138
|
+
const flags = field as {
|
|
139
|
+
readonly searchable?: boolean;
|
|
140
|
+
readonly sortable?: boolean;
|
|
141
|
+
readonly sensitive?: boolean;
|
|
142
|
+
}; // @cast-boundary schema-walk
|
|
143
|
+
if ((annotCount > 0 || piiEncryptedFlag.piiEncrypted === true) && flags.sortable === true) {
|
|
139
144
|
throw new Error(
|
|
140
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with {
|
|
145
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use { lookupable: true }; drop sortable or keep the field plaintext (allowPlaintext).`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (flags.sensitive === true && flags.searchable === true) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means nobody may read the value back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not.`,
|
|
141
151
|
);
|
|
142
152
|
}
|
|
143
153
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Per-feature changelog — each bundled feature has a `changes.json` that
|
|
2
|
+
// tracks breaking changes, improvements, and fixes per version. The CLI
|
|
3
|
+
// (`kumiko upgrade`) reads these to show apps what they need to migrate.
|
|
4
|
+
// All fields should be in English for consistency across the codebase.
|
|
5
|
+
//
|
|
6
|
+
// File I/O stays in the CLI (`bin/commands/upgrade.ts`) — this module is
|
|
7
|
+
// pure parse/validate so engine stays off the node:fs allowlist.
|
|
8
|
+
|
|
9
|
+
export type ChangelogType = "breaking" | "improvement" | "fix";
|
|
10
|
+
|
|
11
|
+
export type ChangelogEntry = {
|
|
12
|
+
readonly version: string;
|
|
13
|
+
readonly type: ChangelogType;
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly detail?: string;
|
|
16
|
+
/** Required when type=breaking. Shown in `kumiko upgrade` output. */
|
|
17
|
+
readonly migration?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type FeatureChangelog = {
|
|
21
|
+
readonly feature: string;
|
|
22
|
+
readonly entries: readonly ChangelogEntry[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Parse a changes.json body. Callers own file I/O. */
|
|
26
|
+
export function parseFeatureChangelog(raw: string, featureName: string): FeatureChangelog | null {
|
|
27
|
+
try {
|
|
28
|
+
const entries = JSON.parse(raw) as unknown;
|
|
29
|
+
if (!Array.isArray(entries)) return null;
|
|
30
|
+
|
|
31
|
+
const validated: ChangelogEntry[] = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!isChangelogEntry(entry)) continue;
|
|
34
|
+
validated.push(entry);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { feature: featureName, entries: validated };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isChangelogEntry(value: unknown): value is ChangelogEntry {
|
|
44
|
+
if (typeof value !== "object" || value === null) return false;
|
|
45
|
+
const obj = value as Record<string, unknown>;
|
|
46
|
+
if (typeof obj["version"] !== "string") return false;
|
|
47
|
+
if (!["breaking", "improvement", "fix"].includes(obj["type"] as string)) return false;
|
|
48
|
+
if (typeof obj["title"] !== "string") return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateChangelog(entry: ChangelogEntry): string[] {
|
|
53
|
+
const errors: string[] = [];
|
|
54
|
+
if (entry.type === "breaking" && !entry.migration) {
|
|
55
|
+
errors.push(`breaking change "${entry.title}" missing migration field`);
|
|
56
|
+
}
|
|
57
|
+
if (entry.type === "breaking" && entry.migration?.trim() === "") {
|
|
58
|
+
errors.push(`breaking change "${entry.title}" has empty migration field`);
|
|
59
|
+
}
|
|
60
|
+
return errors;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function compareVersions(a: string, b: string): number {
|
|
64
|
+
const pa = a.split(".").map(Number);
|
|
65
|
+
const pb = b.split(".").map(Number);
|
|
66
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
67
|
+
const na = pa[i] ?? 0;
|
|
68
|
+
const nb = pb[i] ?? 0;
|
|
69
|
+
if (na > nb) return 1;
|
|
70
|
+
if (na < nb) return -1;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function filterEntriesAfter(
|
|
76
|
+
entries: readonly ChangelogEntry[],
|
|
77
|
+
version: string,
|
|
78
|
+
): readonly ChangelogEntry[] {
|
|
79
|
+
return entries.filter((e) => compareVersions(e.version, version) > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function sortEntries(entries: readonly ChangelogEntry[]): readonly ChangelogEntry[] {
|
|
83
|
+
const order: Record<ChangelogType, number> = {
|
|
84
|
+
breaking: 0,
|
|
85
|
+
improvement: 1,
|
|
86
|
+
fix: 2,
|
|
87
|
+
};
|
|
88
|
+
return [...entries].sort((a, b) => {
|
|
89
|
+
const typeDiff = order[a.type] - order[b.type];
|
|
90
|
+
if (typeDiff !== 0) return typeDiff;
|
|
91
|
+
return compareVersions(b.version, a.version);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { compareByCodepoint } from "../utils";
|
|
10
10
|
import { isEncryptedAtRest } from "./config-helpers";
|
|
11
|
+
import type { ChangelogEntry } from "./feature-changelog";
|
|
11
12
|
import { qualifyEntityName } from "./qualified-name";
|
|
12
13
|
import type { Registry, UiHints } from "./types/feature";
|
|
13
14
|
|
|
@@ -64,6 +65,9 @@ export type ManifestFeature = {
|
|
|
64
65
|
readonly uiHints?: UiHints;
|
|
65
66
|
/** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
|
|
66
67
|
readonly tier?: string;
|
|
68
|
+
/** Per-feature changelog entries (from changes.json). Optional —
|
|
69
|
+
* absent when no changes.json exists or feature has no entries. */
|
|
70
|
+
readonly changelog?: readonly ChangelogEntry[];
|
|
67
71
|
};
|
|
68
72
|
|
|
69
73
|
export type FeatureManifest = {
|
package/src/engine/index.ts
CHANGED
|
@@ -163,6 +163,16 @@ export {
|
|
|
163
163
|
replacePattern,
|
|
164
164
|
VERSION_HEADER,
|
|
165
165
|
} from "./feature-ast";
|
|
166
|
+
export {
|
|
167
|
+
type ChangelogEntry,
|
|
168
|
+
type ChangelogType,
|
|
169
|
+
compareVersions,
|
|
170
|
+
type FeatureChangelog,
|
|
171
|
+
filterEntriesAfter,
|
|
172
|
+
parseFeatureChangelog,
|
|
173
|
+
sortEntries,
|
|
174
|
+
validateChangelog,
|
|
175
|
+
} from "./feature-changelog";
|
|
166
176
|
export {
|
|
167
177
|
type BuildManifestOptions,
|
|
168
178
|
buildManifestFromRegistry,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { schedulerIdForJobName } from "../job-runner";
|
|
3
|
+
|
|
4
|
+
describe("schedulerIdForJobName", () => {
|
|
5
|
+
test("strips dots and colons so BullMQ job ids stay under the 5-segment legacy heuristic", () => {
|
|
6
|
+
// Job id becomes repeat:<id>:<millis> — colons in <id> previously pushed
|
|
7
|
+
// the segment count to ≥5 and leaked a permanent hash per cron tick
|
|
8
|
+
// (fw#1603 / bullmq#3828).
|
|
9
|
+
const id = schedulerIdForJobName("publicstatus:job:uptime-probe");
|
|
10
|
+
expect(id).toBe("scheduler-publicstatus-job-uptime-probe");
|
|
11
|
+
expect(id.includes(":")).toBe(false);
|
|
12
|
+
expect(`repeat:${id}:1784992080000`.split(":").length).toBeLessThan(5);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("still collapses dotted QNs", () => {
|
|
16
|
+
expect(schedulerIdForJobName("app.job.tick")).toBe("scheduler-app-job-tick");
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/jobs/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { JobLogEntry, JobMeta, JobRunner, JobRunnerOptions } from "./job-runner";
|
|
2
|
-
export { createJobRunner } from "./job-runner";
|
|
2
|
+
export { createJobRunner, schedulerIdForJobName } from "./job-runner";
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -34,6 +34,22 @@ function queueNameFor(prefix: string, lane: JobRunIn): string {
|
|
|
34
34
|
return `${prefix}-${lane}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
|
|
39
|
+
* scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
|
|
40
|
+
* treated as old repeatables — spawning a new scheduler entry every tick and
|
|
41
|
+
* leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
|
|
42
|
+
* publicstatus Redis OOM). Strip `.` and `:` from the job QN.
|
|
43
|
+
*/
|
|
44
|
+
export function schedulerIdForJobName(jobName: string): string {
|
|
45
|
+
return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pre-sanitize id (`.` only) — remove on boot so colon-form ghosts die. */
|
|
49
|
+
function legacySchedulerIdForJobName(jobName: string): string {
|
|
50
|
+
return `scheduler-${jobName.replace(/\./g, "-")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
export type JobLogEntry = {
|
|
38
54
|
level: "info" | "warn" | "error";
|
|
39
55
|
message: string;
|
|
@@ -473,12 +489,26 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
473
489
|
for (const [name, jobDef] of allJobs) {
|
|
474
490
|
if (laneForJob(jobDef) !== consumerLane) continue;
|
|
475
491
|
if ("cron" in jobDef.trigger) {
|
|
492
|
+
const schedulerId = schedulerIdForJobName(name);
|
|
493
|
+
const legacyId = legacySchedulerIdForJobName(name);
|
|
494
|
+
// Drop pre-sanitize scheduler ids so colon-form ghosts stop firing.
|
|
495
|
+
if (legacyId !== schedulerId) {
|
|
496
|
+
try {
|
|
497
|
+
await consumerQueue.removeJobScheduler(legacyId);
|
|
498
|
+
} catch {
|
|
499
|
+
// skip: legacy scheduler absent (fresh install / already purged)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
476
502
|
await consumerQueue.upsertJobScheduler(
|
|
477
|
-
|
|
503
|
+
schedulerId,
|
|
478
504
|
{ pattern: jobDef.trigger.cron },
|
|
479
505
|
{
|
|
480
506
|
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
481
507
|
data: {},
|
|
508
|
+
opts: {
|
|
509
|
+
removeOnComplete: { count: 100 },
|
|
510
|
+
removeOnFail: { count: 50 },
|
|
511
|
+
},
|
|
482
512
|
},
|
|
483
513
|
);
|
|
484
514
|
}
|
|
@@ -59,9 +59,12 @@ async function* executeStreamInner(
|
|
|
59
59
|
const invalidated = new Promise<void>((resolve) => {
|
|
60
60
|
resolveInvalidated = resolve;
|
|
61
61
|
});
|
|
62
|
-
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation?.(
|
|
63
|
+
user.id,
|
|
64
|
+
() => {
|
|
65
|
+
resolveInvalidated?.();
|
|
66
|
+
},
|
|
67
|
+
);
|
|
65
68
|
|
|
66
69
|
let iterator: AsyncIterator<unknown> | undefined;
|
|
67
70
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { SseBroker } from "../api/sse-broker";
|
|
2
|
+
import {
|
|
3
|
+
collectSearchableSubjectFields,
|
|
4
|
+
configuredPiiSubjectKms,
|
|
5
|
+
decryptPiiFieldValues,
|
|
6
|
+
isPiiCiphertext,
|
|
7
|
+
PII_ERASED_SENTINEL,
|
|
8
|
+
} from "../crypto";
|
|
2
9
|
import type { DbRow } from "../db/connection";
|
|
3
10
|
import { tenantChannel } from "../engine/constants";
|
|
4
11
|
import type { EntityId, JobRunnerRef, Registry, SessionUser } from "../engine/types";
|
|
@@ -53,10 +60,9 @@ export function createSearchEventConsumer(
|
|
|
53
60
|
const verb = event.type.split(".").pop();
|
|
54
61
|
const tenantId = event.tenantId;
|
|
55
62
|
|
|
56
|
-
// skip: delete
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
if (verb === "deleted") {
|
|
63
|
+
// skip: delete/forgotten remove the index entry — reconstruct only
|
|
64
|
+
// makes sense for created/updated/restored (field data in payload).
|
|
65
|
+
if (verb === "deleted" || verb === "forgotten") {
|
|
60
66
|
await searchAdapter.remove(tenantId, entityName, event.aggregateId);
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
@@ -68,7 +74,13 @@ export function createSearchEventConsumer(
|
|
|
68
74
|
return;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
let state = reconstructStateForSearch(event.payload, verb);
|
|
78
|
+
state = await decryptSearchableSubjectFields(entityName, state, registry);
|
|
79
|
+
// skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
|
|
80
|
+
if (hasErasedSearchableSubjectField(entityName, state, registry)) {
|
|
81
|
+
await searchAdapter.remove(tenantId, entityName, event.aggregateId);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
72
84
|
const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
|
|
73
85
|
if (!doc) {
|
|
74
86
|
// skip: entity isn't searchable (no searchable fields declared)
|
|
@@ -79,6 +91,41 @@ export function createSearchEventConsumer(
|
|
|
79
91
|
};
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
// #1610 — subject-annotated searchable fields are ciphertext in the event
|
|
95
|
+
// payload; decrypt into the derived index only. No KMS → omit ciphertext
|
|
96
|
+
// values rather than indexing blobs.
|
|
97
|
+
async function decryptSearchableSubjectFields(
|
|
98
|
+
entityName: string,
|
|
99
|
+
state: Record<string, unknown>,
|
|
100
|
+
registry: Registry,
|
|
101
|
+
): Promise<Record<string, unknown>> {
|
|
102
|
+
const entity = registry.getEntity(entityName);
|
|
103
|
+
if (!entity) return state;
|
|
104
|
+
const fields = collectSearchableSubjectFields(entity);
|
|
105
|
+
if (fields.length === 0) return state;
|
|
106
|
+
const kms = configuredPiiSubjectKms();
|
|
107
|
+
if (!kms) {
|
|
108
|
+
const out = { ...state };
|
|
109
|
+
for (const name of fields) {
|
|
110
|
+
if (isPiiCiphertext(out[name])) delete out[name];
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
return decryptPiiFieldValues(state, fields, kms, {
|
|
115
|
+
requestId: "system:consumer:search",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasErasedSearchableSubjectField(
|
|
120
|
+
entityName: string,
|
|
121
|
+
state: Record<string, unknown>,
|
|
122
|
+
registry: Registry,
|
|
123
|
+
): boolean {
|
|
124
|
+
const entity = registry.getEntity(entityName);
|
|
125
|
+
if (!entity) return false;
|
|
126
|
+
return collectSearchableSubjectFields(entity).some((name) => state[name] === PII_ERASED_SENTINEL);
|
|
127
|
+
}
|
|
128
|
+
|
|
82
129
|
// Rebuild the entity-state a search index needs from the event-payload alone.
|
|
83
130
|
// Three shapes to handle — see event-store-executor.ts for the emitter side.
|
|
84
131
|
function reconstructStateForSearch(
|
|
@@ -369,7 +416,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
369
416
|
// poison would otherwise permanently stop access-invalidation for
|
|
370
417
|
// every user behind one bad row).
|
|
371
418
|
if (typeof userId !== "string" || userId.length === 0) return;
|
|
372
|
-
sseBroker.publishAccessInvalidation(userId);
|
|
419
|
+
sseBroker.publishAccessInvalidation?.(userId);
|
|
373
420
|
}
|
|
374
421
|
|
|
375
422
|
if (
|
|
@@ -380,7 +427,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
380
427
|
// skip: previous snapshot missing/malformed userId — same fail-open
|
|
381
428
|
// reasoning as above.
|
|
382
429
|
if (userId === undefined) return;
|
|
383
|
-
sseBroker.publishAccessInvalidation(userId);
|
|
430
|
+
sseBroker.publishAccessInvalidation?.(userId);
|
|
384
431
|
}
|
|
385
432
|
},
|
|
386
433
|
};
|
package/src/schema-cli.ts
CHANGED
|
@@ -273,11 +273,10 @@ export async function runSchemaCli(
|
|
|
273
273
|
|
|
274
274
|
// 3. Migration-content drift — replay the committed *.sql files and
|
|
275
275
|
// diff the reconstructed schema against .snapshot.json.
|
|
276
|
-
|
|
277
|
-
if (existsSync(migrationsDir) && committedSnapshot !== null) {
|
|
276
|
+
if (existsSync(migrationsDir) && prevSnapshot !== null) {
|
|
278
277
|
try {
|
|
279
278
|
const replayed = replayMigrationsDir(migrationsDir);
|
|
280
|
-
const mismatches = diffReplayAgainstSnapshot(replayed,
|
|
279
|
+
const mismatches = diffReplayAgainstSnapshot(replayed, prevSnapshot);
|
|
281
280
|
if (mismatches.length === 0) {
|
|
282
281
|
out.log(" ✓ migrations: table/column names match .snapshot.json");
|
|
283
282
|
} else {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// fw#1610 — subject-annotated searchable fields: ciphertext in events,
|
|
2
|
+
// plaintext in derived search index, purged on subject erase.
|
|
3
|
+
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
configurePiiSubjectKms,
|
|
7
|
+
InMemoryKmsAdapter,
|
|
8
|
+
isPiiCiphertext,
|
|
9
|
+
resetPiiSubjectKmsForTests,
|
|
10
|
+
subjectIdToKey,
|
|
11
|
+
} from "../../crypto";
|
|
12
|
+
import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
|
|
13
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
14
|
+
import { createEventsTable } from "../../event-store";
|
|
15
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
16
|
+
import { purgeSearchDocumentsForSubject } from "../purge-subject";
|
|
17
|
+
|
|
18
|
+
const contactEntity = createEntity({
|
|
19
|
+
table: "read_search_pii_contacts",
|
|
20
|
+
fields: {
|
|
21
|
+
// pii: true → subject = entity id (self). searchable via derived index.
|
|
22
|
+
label: createTextField({ required: true, maxLength: 100, pii: true, searchable: true }),
|
|
23
|
+
note: createTextField({ required: true, maxLength: 100, searchable: true }),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const contactTable = buildEntityTable("contact", contactEntity);
|
|
28
|
+
|
|
29
|
+
const contactFeature = defineFeature("search-pii-probe", (r) => {
|
|
30
|
+
r.entity("contact", contactEntity);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let stack: TestStack;
|
|
34
|
+
let kms: InMemoryKmsAdapter;
|
|
35
|
+
const admin = TestUsers.admin;
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
stack = await setupTestStack({ features: [contactFeature] });
|
|
39
|
+
await unsafeCreateEntityTable(stack.db, contactEntity, "contact");
|
|
40
|
+
await createEventsTable(stack.db);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await stack.cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
kms = new InMemoryKmsAdapter();
|
|
49
|
+
configurePiiSubjectKms(kms);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
resetPiiSubjectKmsForTests();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function executor() {
|
|
57
|
+
return createEventStoreExecutor(contactTable, contactEntity, {
|
|
58
|
+
entityName: "contact",
|
|
59
|
+
searchAdapter: stack.search,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tenantDb() {
|
|
64
|
+
return createTenantDb(stack.db, admin.tenantId, "system");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("searchable PII derived index (#1610)", () => {
|
|
68
|
+
test("create indexes plaintext; event payload stays ciphertext; erase purges search", async () => {
|
|
69
|
+
const plain = "UniqueSearchPiiLabel1610";
|
|
70
|
+
const created = await executor().create(
|
|
71
|
+
{ label: plain, note: "public-note" },
|
|
72
|
+
admin,
|
|
73
|
+
tenantDb(),
|
|
74
|
+
);
|
|
75
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
76
|
+
const id = String(created.data.id);
|
|
77
|
+
|
|
78
|
+
const events = await asRawClient(stack.db).unsafe(
|
|
79
|
+
`SELECT payload FROM kumiko_events WHERE aggregate_id = $1 AND type = 'contact.created' LIMIT 1`,
|
|
80
|
+
[id],
|
|
81
|
+
);
|
|
82
|
+
const payload = (events as { payload: Record<string, unknown> }[])[0]?.payload;
|
|
83
|
+
expect(isPiiCiphertext(payload?.["label"])).toBe(true);
|
|
84
|
+
expect(payload?.["label"]).not.toBe(plain);
|
|
85
|
+
|
|
86
|
+
await stack.eventDispatcher?.runOnce();
|
|
87
|
+
|
|
88
|
+
const hits = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
|
|
89
|
+
expect(hits.some((h) => String(h.entityId) === id)).toBe(true);
|
|
90
|
+
|
|
91
|
+
// pii: true → subject key is the entity id itself.
|
|
92
|
+
const subject = { kind: "user" as const, userId: id };
|
|
93
|
+
await kms.eraseKey(subject);
|
|
94
|
+
await purgeSearchDocumentsForSubject(
|
|
95
|
+
stack.db,
|
|
96
|
+
stack.registry.features,
|
|
97
|
+
stack.search,
|
|
98
|
+
subjectIdToKey(subject),
|
|
99
|
+
subject,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
|
|
103
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("consumer treats erased decrypt as remove (no sentinel index)", async () => {
|
|
107
|
+
const plain = "SentinelRebuildLabel1610";
|
|
108
|
+
const created = await executor().create({ label: plain, note: "x" }, admin, tenantDb());
|
|
109
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
110
|
+
const id = String(created.data.id);
|
|
111
|
+
|
|
112
|
+
await stack.eventDispatcher?.runOnce();
|
|
113
|
+
expect(
|
|
114
|
+
(await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
|
|
115
|
+
(h) => String(h.entityId) === id,
|
|
116
|
+
),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
|
|
119
|
+
await kms.eraseKey({ kind: "user", userId: id });
|
|
120
|
+
// Force re-index path by updating a non-PII field — consumer decrypts
|
|
121
|
+
// label → [[erased]] → remove.
|
|
122
|
+
const updated = await executor().update(
|
|
123
|
+
{ id: created.data.id, changes: { note: "y" } },
|
|
124
|
+
admin,
|
|
125
|
+
tenantDb(),
|
|
126
|
+
{ skipOptimisticLock: true },
|
|
127
|
+
);
|
|
128
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
129
|
+
await stack.eventDispatcher?.runOnce();
|
|
130
|
+
|
|
131
|
+
const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
|
|
132
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
test("purge finds rows after anonymize rewrote ciphertext (#1610 bugbot)", async () => {
|
|
135
|
+
const plain = "AnonymizedStillPurge1610";
|
|
136
|
+
const created = await executor().create({ label: plain, note: "n" }, admin, tenantDb());
|
|
137
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
138
|
+
const id = String(created.data.id);
|
|
139
|
+
await stack.eventDispatcher?.runOnce();
|
|
140
|
+
expect(
|
|
141
|
+
(await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
|
|
142
|
+
(h) => String(h.entityId) === id,
|
|
143
|
+
),
|
|
144
|
+
).toBe(true);
|
|
145
|
+
|
|
146
|
+
// Simulate forget-cleanup anonymize: overwrite searchable PII with plaintext.
|
|
147
|
+
await asRawClient(stack.db).unsafe(
|
|
148
|
+
`UPDATE read_search_pii_contacts SET label = $1 WHERE id = $2`,
|
|
149
|
+
["[[erased]]", id],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const subject = { kind: "user" as const, userId: id };
|
|
153
|
+
await purgeSearchDocumentsForSubject(
|
|
154
|
+
stack.db,
|
|
155
|
+
stack.registry.features,
|
|
156
|
+
stack.search,
|
|
157
|
+
subjectIdToKey(subject),
|
|
158
|
+
subject,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
|
|
162
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/search/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// von SearchAdapter-Types. Apps die Meilisearch nicht nutzen, ziehen den
|
|
4
4
|
// Client-Code nicht mit rein.
|
|
5
5
|
export { createInMemorySearchAdapter } from "./in-memory-adapter";
|
|
6
|
+
export { purgeSearchDocumentsForSubject } from "./purge-subject";
|
|
6
7
|
export type {
|
|
7
8
|
ReindexEntityFailure,
|
|
8
9
|
ReindexEntityOptions,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Purge derived search documents for an erased PII subject (#1610).
|
|
2
|
+
//
|
|
3
|
+
// After kms.eraseKey the projection/event ciphertext is unreadable, but Meili
|
|
4
|
+
// still holds the plaintext that createSearchEventConsumer decrypted into the
|
|
5
|
+
// index. Discovery is dual-path:
|
|
6
|
+
// 1. Ownership: pii self-id / userOwned.ownerField / tenantOwned.tenantId
|
|
7
|
+
// (survives anonymize hooks that overwrite ciphertext with plaintext).
|
|
8
|
+
// 2. Ciphertext LIKE prefix (same as nullBlindIndexesForSubject) for rows
|
|
9
|
+
// that still carry the subject key in encrypted columns.
|
|
10
|
+
|
|
11
|
+
import type { SubjectId } from "../crypto/kms-adapter";
|
|
12
|
+
import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
|
|
13
|
+
import type { DbRunner } from "../db/connection";
|
|
14
|
+
import { resolveTableName } from "../db/entity-table-meta";
|
|
15
|
+
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
16
|
+
import type { FeatureDefinition } from "../engine/types";
|
|
17
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
18
|
+
import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
19
|
+
import { toSnakeCase } from "../utils/case";
|
|
20
|
+
import type { SearchAdapter } from "./types";
|
|
21
|
+
|
|
22
|
+
function quoteIdent(name: string): string {
|
|
23
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapeLikePattern(value: string): string {
|
|
27
|
+
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
|
|
31
|
+
function ownershipPredicates(
|
|
32
|
+
entity: EntityDefinition,
|
|
33
|
+
searchableFields: readonly string[],
|
|
34
|
+
subject: SubjectId,
|
|
35
|
+
nextParam: () => number,
|
|
36
|
+
): { sql: string; params: unknown[] } | null {
|
|
37
|
+
const parts: string[] = [];
|
|
38
|
+
const params: unknown[] = [];
|
|
39
|
+
let selfIdN: number | undefined;
|
|
40
|
+
let tenantIdN: number | undefined;
|
|
41
|
+
const ownerFieldN = new Map<string, number>();
|
|
42
|
+
|
|
43
|
+
for (const fieldName of searchableFields) {
|
|
44
|
+
const field = entity.fields[fieldName];
|
|
45
|
+
if (!field) continue;
|
|
46
|
+
if (subject.kind === "user") {
|
|
47
|
+
if ("userOwned" in field && field.userOwned !== undefined) {
|
|
48
|
+
const col = toSnakeCase(field.userOwned.ownerField);
|
|
49
|
+
let n = ownerFieldN.get(col);
|
|
50
|
+
if (n === undefined) {
|
|
51
|
+
n = nextParam();
|
|
52
|
+
ownerFieldN.set(col, n);
|
|
53
|
+
params.push(subject.userId);
|
|
54
|
+
parts.push(`${quoteIdent(col)} = $${n}`);
|
|
55
|
+
}
|
|
56
|
+
} else if ("pii" in field && field.pii === true && selfIdN === undefined) {
|
|
57
|
+
selfIdN = nextParam();
|
|
58
|
+
params.push(subject.userId);
|
|
59
|
+
parts.push(`${quoteIdent("id")} = $${selfIdN}`);
|
|
60
|
+
}
|
|
61
|
+
} else if ("tenantOwned" in field && field.tenantOwned === true && tenantIdN === undefined) {
|
|
62
|
+
tenantIdN = nextParam();
|
|
63
|
+
params.push(subject.tenantId);
|
|
64
|
+
parts.push(`${quoteIdent("tenant_id")} = $${tenantIdN}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (parts.length === 0) return null;
|
|
68
|
+
return { sql: parts.join(" OR "), params };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function purgeSearchDocumentsForSubject(
|
|
72
|
+
db: DbRunner,
|
|
73
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
74
|
+
search: SearchAdapter,
|
|
75
|
+
subjectKey: string,
|
|
76
|
+
/** When set, also match rows by ownership — needed after anonymize rewrites ciphertext. */
|
|
77
|
+
subject?: SubjectId,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
80
|
+
const byTenant = new Map<string, { entityType: string; entityId: EntityId }[]>();
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
|
|
83
|
+
for (const feature of features.values()) {
|
|
84
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
85
|
+
const fields = collectSearchableSubjectFields(entity);
|
|
86
|
+
if (fields.length === 0) continue;
|
|
87
|
+
const tableName = resolveTableName(entityName, entity, undefined);
|
|
88
|
+
|
|
89
|
+
let paramIdx = 0;
|
|
90
|
+
const nextParam = () => ++paramIdx;
|
|
91
|
+
const params: unknown[] = [];
|
|
92
|
+
const orParts: string[] = [];
|
|
93
|
+
|
|
94
|
+
const likeN = nextParam();
|
|
95
|
+
params.push(likePattern);
|
|
96
|
+
orParts.push(
|
|
97
|
+
`(${fields.map((f) => `${quoteIdent(toSnakeCase(f))} LIKE $${likeN}`).join(" OR ")})`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (subject) {
|
|
101
|
+
const owned = ownershipPredicates(entity, fields, subject, nextParam);
|
|
102
|
+
if (owned) {
|
|
103
|
+
params.push(...owned.params);
|
|
104
|
+
orParts.push(`(${owned.sql})`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const rows = await executeRawQuery<{ id: string; tenant_id: string }>(
|
|
109
|
+
db,
|
|
110
|
+
`SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${orParts.join(" OR ")}`,
|
|
111
|
+
params,
|
|
112
|
+
);
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const key = `${row.tenant_id}:${entityName}:${row.id}`;
|
|
115
|
+
if (seen.has(key)) continue;
|
|
116
|
+
seen.add(key);
|
|
117
|
+
const list = byTenant.get(row.tenant_id) ?? [];
|
|
118
|
+
list.push({ entityType: entityName, entityId: row.id as EntityId });
|
|
119
|
+
byTenant.set(row.tenant_id, list);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [tenantId, items] of byTenant) {
|
|
125
|
+
if (items.length === 0) continue;
|
|
126
|
+
const tid = tenantId as TenantId;
|
|
127
|
+
if (search.removeBatch) {
|
|
128
|
+
await search.removeBatch(tid, items);
|
|
129
|
+
} else {
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
await search.remove(tid, item.entityType, item.entityId);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -2,15 +2,18 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { waitFor } from "../wait-for";
|
|
3
3
|
|
|
4
4
|
describe("waitFor", () => {
|
|
5
|
-
test("
|
|
5
|
+
test("calls fn exactly once when it passes on the first attempt (no prior sleep)", async () => {
|
|
6
6
|
let calls = 0;
|
|
7
|
+
const started = Date.now();
|
|
7
8
|
await waitFor(
|
|
8
9
|
() => {
|
|
9
10
|
calls++;
|
|
10
11
|
},
|
|
11
|
-
{ delays: [
|
|
12
|
+
{ delays: [200, 200, 200] },
|
|
12
13
|
);
|
|
13
14
|
expect(calls).toBe(1);
|
|
15
|
+
// try-first: must not burn the first delay when the condition already holds
|
|
16
|
+
expect(Date.now() - started).toBeLessThan(100);
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
test("retries on failure and succeeds once fn passes", async () => {
|
|
@@ -35,8 +38,9 @@ describe("waitFor", () => {
|
|
|
35
38
|
},
|
|
36
39
|
{ delays: [1, 1] },
|
|
37
40
|
),
|
|
38
|
-
).rejects.toThrow("fail-
|
|
39
|
-
|
|
41
|
+
).rejects.toThrow("fail-3");
|
|
42
|
+
// N delays → N+1 attempts (final try after the last backoff)
|
|
43
|
+
expect(calls).toBe(3);
|
|
40
44
|
});
|
|
41
45
|
|
|
42
46
|
test("throws a descriptive error for an empty delay schedule", async () => {
|
package/src/testing/wait-for.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Polls a condition with escalating timeouts.
|
|
3
3
|
*
|
|
4
|
-
* Default schedule: 250ms → 1s → 3s
|
|
5
|
-
*
|
|
4
|
+
* Default schedule: 250ms → 1s → 3s between attempts. Tries first (already-true
|
|
5
|
+
* returns immediately), then sleeps `delays[i]` after each failure before the
|
|
6
|
+
* next try — so N delays yield N+1 attempts and the full backoff budget.
|
|
7
|
+
* Throws the last assertion error if all attempts fail.
|
|
6
8
|
*
|
|
7
9
|
* Usage:
|
|
8
10
|
* await waitFor(() => {
|
|
@@ -19,15 +21,18 @@ export async function waitFor(
|
|
|
19
21
|
}
|
|
20
22
|
let lastError: unknown;
|
|
21
23
|
|
|
22
|
-
for (let i = 0;
|
|
23
|
-
await new Promise((r) => setTimeout(r, delays[i]));
|
|
24
|
+
for (let i = 0; ; i++) {
|
|
24
25
|
try {
|
|
25
26
|
await fn();
|
|
26
|
-
// skip:
|
|
27
|
+
// skip: condition already true — no further polling
|
|
27
28
|
return;
|
|
28
29
|
} catch (err) {
|
|
29
30
|
lastError = err;
|
|
30
31
|
}
|
|
32
|
+
if (i >= delays.length) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
await new Promise((r) => setTimeout(r, delays[i]));
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
throw lastError;
|