@cosmicdrift/kumiko-bundled-features 0.154.1 → 0.155.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/package.json +6 -6
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +44 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +130 -0
- package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +89 -0
- package/src/auth-email-password/web/auth-form-logic.ts +30 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +4 -2
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -7
- package/src/auth-email-password/web/signup-screen.tsx +2 -4
- package/src/auth-mfa/web/mfa-enable-screen.tsx +29 -1
- package/src/custom-fields/wire-for-entity.ts +1 -1
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +18 -2
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +307 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +271 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +306 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +51 -0
- package/src/legal-pages/feature.ts +3 -0
- package/src/sessions/feature.ts +1 -1
- package/src/tier-engine/feature.ts +4 -4
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Full-stack IMAP integration: setupTestStack + secrets + inbound-provider-imap
|
|
2
|
+
// + watch-supervisor against greenmail. Proves the production path
|
|
3
|
+
// (connect → credential secret → poll fetch → ingest projection), not the
|
|
4
|
+
// plugin-only live suite in imap-live.integration.test.ts.
|
|
5
|
+
//
|
|
6
|
+
// Opt-in via greenmail (same as imap-live):
|
|
7
|
+
// docker start cdgs-greenmail
|
|
8
|
+
// # or: docker run -d --name cdgs-greenmail -p 3025:3025 -p 3143:3143 \
|
|
9
|
+
// # -e GREENMAIL_OPTS="-Dgreenmail.setup.test.all -Dgreenmail.users=testuser:testpass@example.com" \
|
|
10
|
+
// # greenmail/standalone:2.1.0
|
|
11
|
+
//
|
|
12
|
+
// When greenmail is down the suite skips (visible), never fakes IMAP.
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { connect } from "node:net";
|
|
17
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
18
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
+
import {
|
|
20
|
+
configurePiiSubjectKms,
|
|
21
|
+
InMemoryKmsAdapter,
|
|
22
|
+
resetPiiSubjectKmsForTests,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
24
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
+
import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
27
|
+
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
28
|
+
import {
|
|
29
|
+
createTestUser,
|
|
30
|
+
setupTestStack,
|
|
31
|
+
type TestStack,
|
|
32
|
+
testTenantId,
|
|
33
|
+
unsafeCreateEntityTable,
|
|
34
|
+
unsafePushTables,
|
|
35
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
36
|
+
import {
|
|
37
|
+
createMutableMasterKeyProvider,
|
|
38
|
+
type MutableMasterKeyProvider,
|
|
39
|
+
waitFor,
|
|
40
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
41
|
+
import { createTransport } from "nodemailer";
|
|
42
|
+
import {
|
|
43
|
+
createComplianceProfilesFeature,
|
|
44
|
+
tenantComplianceProfileEntity,
|
|
45
|
+
} from "../../compliance-profiles";
|
|
46
|
+
import { createConfigFeature } from "../../config";
|
|
47
|
+
import {
|
|
48
|
+
createInboundMailSupervisor,
|
|
49
|
+
InboundMailAccountStatuses,
|
|
50
|
+
InboundMailFoundationHandlers,
|
|
51
|
+
InboundMailFoundationQueries,
|
|
52
|
+
inboundCredentialSecretKey,
|
|
53
|
+
inboundMailFoundationFeature,
|
|
54
|
+
mailAccountsProjectionTable,
|
|
55
|
+
seenMessageEntity,
|
|
56
|
+
syncCursorEntity,
|
|
57
|
+
} from "../../inbound-mail-foundation";
|
|
58
|
+
import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
|
|
59
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
60
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
61
|
+
import { inboundProviderImapFeature } from "../feature";
|
|
62
|
+
|
|
63
|
+
const HOST = process.env["IMAP_LIVE_HOST"] ?? "127.0.0.1";
|
|
64
|
+
const IMAP_PORT = Number(process.env["IMAP_LIVE_PORT"] ?? 3143);
|
|
65
|
+
const SMTP_PORT = Number(process.env["IMAP_LIVE_SMTP_PORT"] ?? 3025);
|
|
66
|
+
const USER = "testuser";
|
|
67
|
+
const PASSWORD = "testpass";
|
|
68
|
+
const ADDRESS = "testuser@example.com";
|
|
69
|
+
|
|
70
|
+
function probe(host: string, port: number): Promise<boolean> {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const socket = connect({ host, port, timeout: 1500 });
|
|
73
|
+
socket.once("connect", () => {
|
|
74
|
+
socket.destroy();
|
|
75
|
+
resolve(true);
|
|
76
|
+
});
|
|
77
|
+
socket.once("error", () => resolve(false));
|
|
78
|
+
socket.once("timeout", () => {
|
|
79
|
+
socket.destroy();
|
|
80
|
+
resolve(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const available = await probe(HOST, IMAP_PORT);
|
|
86
|
+
const liveTest = available ? test : test.skip;
|
|
87
|
+
if (!available) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`imap-foundation: kein IMAP-Server auf ${HOST}:${IMAP_PORT} — Suite wird geskippt (greenmail starten)`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let stack: TestStack;
|
|
94
|
+
let db: DbConnection;
|
|
95
|
+
let secrets: ReturnType<typeof createSecretsContext>;
|
|
96
|
+
let providerRef: MutableMasterKeyProvider;
|
|
97
|
+
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
const initialKp = createEnvMasterKeyProvider({
|
|
100
|
+
env: {
|
|
101
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
102
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
providerRef = createMutableMasterKeyProvider(initialKp);
|
|
106
|
+
|
|
107
|
+
stack = await setupTestStack({
|
|
108
|
+
features: [
|
|
109
|
+
createConfigFeature(),
|
|
110
|
+
createTenantFeature(),
|
|
111
|
+
createSecretsFeature(),
|
|
112
|
+
createComplianceProfilesFeature(),
|
|
113
|
+
createTenantLifecycleFeature(),
|
|
114
|
+
inboundMailFoundationFeature,
|
|
115
|
+
inboundProviderImapFeature,
|
|
116
|
+
],
|
|
117
|
+
masterKeyProvider: providerRef,
|
|
118
|
+
extraContext: ({ db: stackDb }) => ({
|
|
119
|
+
secrets: createSecretsContext({ db: stackDb, masterKeyProvider: providerRef }),
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
db = stack.db;
|
|
123
|
+
secrets = createSecretsContext({ db, masterKeyProvider: providerRef });
|
|
124
|
+
|
|
125
|
+
await createEventsTable(db);
|
|
126
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
127
|
+
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
128
|
+
await unsafeCreateEntityTable(db, seenMessageEntity);
|
|
129
|
+
await unsafePushTables(db, { tenant_secrets: tenantSecretsTable });
|
|
130
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterAll(async () => {
|
|
134
|
+
await stack.cleanup();
|
|
135
|
+
resetPiiSubjectKmsForTests();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function adminFor(tenantNumber: number) {
|
|
139
|
+
return createTestUser({
|
|
140
|
+
id: tenantNumber,
|
|
141
|
+
tenantId: testTenantId(tenantNumber),
|
|
142
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function credentialJson(): string {
|
|
147
|
+
return JSON.stringify({
|
|
148
|
+
host: HOST,
|
|
149
|
+
port: IMAP_PORT,
|
|
150
|
+
secure: false,
|
|
151
|
+
user: USER,
|
|
152
|
+
password: PASSWORD,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function sendTestMail(subject: string, text: string): Promise<void> {
|
|
157
|
+
const transport = createTransport({ host: HOST, port: SMTP_PORT, secure: false });
|
|
158
|
+
await transport.sendMail({
|
|
159
|
+
from: "Sender <sender@example.com>",
|
|
160
|
+
to: ADDRESS,
|
|
161
|
+
subject,
|
|
162
|
+
text,
|
|
163
|
+
});
|
|
164
|
+
transport.close();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createSupervisor() {
|
|
168
|
+
return createInboundMailSupervisor({
|
|
169
|
+
providerCtx: {
|
|
170
|
+
registry: stack.registry,
|
|
171
|
+
secrets,
|
|
172
|
+
// Worker-identity for requireSecretsContext audit on IMAP credential reads.
|
|
173
|
+
_userId: "imap-foundation-supervisor",
|
|
174
|
+
},
|
|
175
|
+
db,
|
|
176
|
+
dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
177
|
+
stack.dispatcher.write(
|
|
178
|
+
handlerQn,
|
|
179
|
+
payload,
|
|
180
|
+
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
181
|
+
),
|
|
182
|
+
pollIntervalMs: 60_000,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
describe("imap-foundation — greenmail + supervisor", () => {
|
|
187
|
+
liveTest(
|
|
188
|
+
"connect + credential secret + pollOnce ingests a real IMAP message",
|
|
189
|
+
async () => {
|
|
190
|
+
const admin = adminFor(4301);
|
|
191
|
+
const connected = (await stack.http.writeOk(
|
|
192
|
+
InboundMailFoundationHandlers.connectAccount,
|
|
193
|
+
{
|
|
194
|
+
provider: "imap",
|
|
195
|
+
authMethod: "password",
|
|
196
|
+
displayName: "Greenmail Inbox",
|
|
197
|
+
address: ADDRESS,
|
|
198
|
+
scope: "shared",
|
|
199
|
+
},
|
|
200
|
+
admin,
|
|
201
|
+
)) as { accountId: string };
|
|
202
|
+
|
|
203
|
+
await secrets.set(
|
|
204
|
+
admin.tenantId,
|
|
205
|
+
inboundCredentialSecretKey(connected.accountId),
|
|
206
|
+
credentialJson(),
|
|
207
|
+
{
|
|
208
|
+
redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
|
|
209
|
+
hint: "IMAP live test credentials",
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const supervisor = createSupervisor();
|
|
214
|
+
// Drain existing mailbox first so a dirty greenmail INBOX (>maxMessages
|
|
215
|
+
// backfill budget) cannot hide the message we are about to send.
|
|
216
|
+
await supervisor.pollOnce();
|
|
217
|
+
|
|
218
|
+
const subject = `foundation-poll-${crypto.randomUUID()}`;
|
|
219
|
+
await sendTestMail(subject, "Hallo aus dem Foundation-Live-Test");
|
|
220
|
+
|
|
221
|
+
await waitFor(async () => {
|
|
222
|
+
await supervisor.pollOnce();
|
|
223
|
+
const list = (await stack.http.queryOk(
|
|
224
|
+
InboundMailFoundationQueries.listMessages,
|
|
225
|
+
{ accountId: connected.accountId },
|
|
226
|
+
admin,
|
|
227
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
228
|
+
expect(list.rows.some((r) => r["subject"] === subject)).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, {
|
|
232
|
+
id: connected.accountId,
|
|
233
|
+
});
|
|
234
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
|
|
235
|
+
expect(accounts[0]?.["provider"]).toBe("imap");
|
|
236
|
+
},
|
|
237
|
+
45_000,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
liveTest(
|
|
241
|
+
"wrong IMAP password → pollOnce marks account auth_error",
|
|
242
|
+
async () => {
|
|
243
|
+
const admin = adminFor(4302);
|
|
244
|
+
const connected = (await stack.http.writeOk(
|
|
245
|
+
InboundMailFoundationHandlers.connectAccount,
|
|
246
|
+
{
|
|
247
|
+
provider: "imap",
|
|
248
|
+
authMethod: "password",
|
|
249
|
+
displayName: "Bad Creds",
|
|
250
|
+
address: ADDRESS,
|
|
251
|
+
scope: "shared",
|
|
252
|
+
},
|
|
253
|
+
admin,
|
|
254
|
+
)) as { accountId: string };
|
|
255
|
+
|
|
256
|
+
await secrets.set(
|
|
257
|
+
admin.tenantId,
|
|
258
|
+
inboundCredentialSecretKey(connected.accountId),
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
host: HOST,
|
|
261
|
+
port: IMAP_PORT,
|
|
262
|
+
secure: false,
|
|
263
|
+
user: USER,
|
|
264
|
+
password: "definitely-wrong",
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const supervisor = createSupervisor();
|
|
269
|
+
await supervisor.pollOnce();
|
|
270
|
+
|
|
271
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, {
|
|
272
|
+
id: connected.accountId,
|
|
273
|
+
});
|
|
274
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.authError);
|
|
275
|
+
},
|
|
276
|
+
30_000,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
liveTest(
|
|
280
|
+
"missing credential secret → pollOnce marks account auth_error",
|
|
281
|
+
async () => {
|
|
282
|
+
const admin = adminFor(4303);
|
|
283
|
+
const connected = (await stack.http.writeOk(
|
|
284
|
+
InboundMailFoundationHandlers.connectAccount,
|
|
285
|
+
{
|
|
286
|
+
provider: "imap",
|
|
287
|
+
authMethod: "password",
|
|
288
|
+
displayName: "No Secret",
|
|
289
|
+
address: ADDRESS,
|
|
290
|
+
scope: "shared",
|
|
291
|
+
},
|
|
292
|
+
admin,
|
|
293
|
+
)) as { accountId: string };
|
|
294
|
+
|
|
295
|
+
// No secrets.set — production path when connect succeeded but slot empty.
|
|
296
|
+
const supervisor = createSupervisor();
|
|
297
|
+
await supervisor.pollOnce();
|
|
298
|
+
|
|
299
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, {
|
|
300
|
+
id: connected.accountId,
|
|
301
|
+
});
|
|
302
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.authError);
|
|
303
|
+
},
|
|
304
|
+
30_000,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
@@ -272,6 +272,57 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
|
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
+
describe("legal-pages :: wrapLayout erhält route.slug (alt-lang-switch-regression)", () => {
|
|
276
|
+
test("custom wrapLayout bekommt den passenden slug pro Route, nicht immer imprint", async () => {
|
|
277
|
+
const seenSlugs: (string | undefined)[] = [];
|
|
278
|
+
const customStack = await setupTestStack({
|
|
279
|
+
features: [
|
|
280
|
+
createTextContentFeature(),
|
|
281
|
+
createLegalPagesFeature({
|
|
282
|
+
wrapLayout: (opts) => {
|
|
283
|
+
seenSlugs.push(opts.slug);
|
|
284
|
+
return `<html data-slug="${opts.slug ?? ""}">${opts.bodyHtml}</html>`;
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
],
|
|
288
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
289
|
+
extraContext: ({ db }) => ({
|
|
290
|
+
textContent: createTextContentApi(db),
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
try {
|
|
294
|
+
await unsafeCreateEntityTable(customStack.db, textBlockEntity);
|
|
295
|
+
await createEventsTable(customStack.db);
|
|
296
|
+
await seedTextBlock(customStack.db, {
|
|
297
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
298
|
+
slug: "imprint",
|
|
299
|
+
lang: "de",
|
|
300
|
+
title: "Impressum",
|
|
301
|
+
body: "Body",
|
|
302
|
+
});
|
|
303
|
+
await seedTextBlock(customStack.db, {
|
|
304
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
305
|
+
slug: "privacy",
|
|
306
|
+
lang: "de",
|
|
307
|
+
title: "Datenschutzerklärung",
|
|
308
|
+
body: "Body",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const imprintRes = await customStack.app.request("/legal/impressum");
|
|
312
|
+
expect(imprintRes.status).toBe(200);
|
|
313
|
+
expect(await imprintRes.text()).toContain('data-slug="imprint"');
|
|
314
|
+
|
|
315
|
+
const privacyRes = await customStack.app.request("/legal/datenschutz");
|
|
316
|
+
expect(privacyRes.status).toBe(200);
|
|
317
|
+
expect(await privacyRes.text()).toContain('data-slug="privacy"');
|
|
318
|
+
|
|
319
|
+
expect(seenSlugs).toEqual(["imprint", "privacy"]);
|
|
320
|
+
} finally {
|
|
321
|
+
await customStack.cleanup();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
275
326
|
describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
276
327
|
// Direkter Test der Boot-Check-Logik mit constructed ctx-Objects —
|
|
277
328
|
// keine JobRunner-Coupling, keine Test-Stacks. Das ist die echte
|
|
@@ -50,6 +50,7 @@ export type LegalPagesWrapLayout = (opts: {
|
|
|
50
50
|
readonly title: string;
|
|
51
51
|
readonly bodyHtml: string;
|
|
52
52
|
readonly lang: string;
|
|
53
|
+
readonly slug?: "imprint" | "privacy" | "terms";
|
|
53
54
|
}) => string;
|
|
54
55
|
|
|
55
56
|
export type LegalPagesOptions = {
|
|
@@ -98,6 +99,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
98
99
|
SYSTEM_TENANT_ID,
|
|
99
100
|
)) as TextBlockQueryResult;
|
|
100
101
|
} catch (err) {
|
|
102
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback, no logger wired into HttpRouteHandlerDeps
|
|
101
103
|
console.error(`legal-pages: text-content query failed for slug="${route.slug}"`, err);
|
|
102
104
|
return c.text("legal page unavailable", 503);
|
|
103
105
|
}
|
|
@@ -134,6 +136,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
134
136
|
title: data.title || route.titleFallback,
|
|
135
137
|
bodyHtml: renderMarkdownToHtml(data.body),
|
|
136
138
|
lang: route.lang,
|
|
139
|
+
slug: route.slug,
|
|
137
140
|
});
|
|
138
141
|
|
|
139
142
|
return cachedSecurePageResponse(c.req.raw, {
|
package/src/sessions/feature.ts
CHANGED
|
@@ -132,7 +132,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
132
132
|
// we want to skip on. Works for both direct user:update calls and any
|
|
133
133
|
// other handler that happens to write the column.
|
|
134
134
|
let autoRevoke = options?.autoRevokeOnPasswordChange;
|
|
135
|
-
r.
|
|
135
|
+
r.hook("postSave", { allOf: "user" }, async (ctx) => {
|
|
136
136
|
// skip: nothing bound — stateless-JWT deployments without a runtime
|
|
137
137
|
// that calls bindAutoRevokeOnPasswordChange keep the old behavior.
|
|
138
138
|
if (!autoRevoke) return;
|
|
@@ -290,7 +290,7 @@ export function createTierEngineFeature<
|
|
|
290
290
|
};
|
|
291
291
|
|
|
292
292
|
// Invalidation: tier-assignment events update the cache.
|
|
293
|
-
r.
|
|
293
|
+
r.hook("postSave", { allOf: "tier-assignment" }, async (result) => {
|
|
294
294
|
// result.data has tenantId + tier (after entity-update merge)
|
|
295
295
|
const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
|
|
296
296
|
// skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
|
|
@@ -302,7 +302,7 @@ export function createTierEngineFeature<
|
|
|
302
302
|
const tenantId = data.tenantId as TenantId;
|
|
303
303
|
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)));
|
|
304
304
|
});
|
|
305
|
-
r.
|
|
305
|
+
r.hook("postDelete", { allOf: "tier-assignment" }, async (payload) => {
|
|
306
306
|
const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
|
|
307
307
|
// skip: gleiche type-guard semantik wie postSave-hook oben.
|
|
308
308
|
if (typeof data.tenantId !== "string") return;
|
|
@@ -323,9 +323,9 @@ export function createTierEngineFeature<
|
|
|
323
323
|
// neuer Tenant (Memory `feedback_event_store_tenant_consistency`).
|
|
324
324
|
if (opts.defaultTier !== undefined) {
|
|
325
325
|
const defaultTier = opts.defaultTier;
|
|
326
|
-
r.
|
|
326
|
+
r.hook(
|
|
327
327
|
"postSave",
|
|
328
|
-
"tenant",
|
|
328
|
+
{ allOf: "tenant" },
|
|
329
329
|
async (result, ctx) => {
|
|
330
330
|
// result-shape: kumiko-framework's SaveContext mit isNew + data
|
|
331
331
|
const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
|