@cosmicdrift/kumiko-bundled-features 0.265.0 → 0.268.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 +9 -9
- package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +747 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +16 -6
- package/src/auth-email-password/handlers/invite-accept.write.ts +19 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +1 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +21 -7
- package/src/auth-email-password/handlers/login.write.ts +3 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +9 -5
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +10 -2
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +11 -1
- package/src/auth-mfa/handlers/verify.write.ts +10 -2
- package/src/auth-mfa/mfa-code-verifier.ts +5 -1
- package/src/auth-mfa/mfa-status-checker.ts +7 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -3
- package/src/cap-counter/stock-cap-guard.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +43 -27
- package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +10 -5
- package/src/custom-fields/db/queries/field-access.ts +3 -1
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/handlers/clear-custom-field.write.ts +6 -0
- package/src/custom-fields/handlers/define-tenant-field.write.ts +6 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +6 -0
- package/src/delivery/jobs.ts +6 -5
- package/src/feature-toggles/global-feature-state-table.ts +15 -11
- package/src/feature-toggles/handlers/list.query.ts +1 -2
- package/src/feature-toggles/handlers/registered.query.ts +1 -2
- package/src/form-draft/handlers/discard.write.ts +7 -1
- package/src/form-draft/handlers/save.write.ts +11 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +3 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +16 -6
- package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -3
- package/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -2
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -4
- package/src/jobs/handlers/reindex-entity.job.ts +5 -2
- package/src/ledger/__tests__/ledger-reports-tenant-isolation.integration.test.ts +148 -0
- package/src/ledger/handlers/confirm-schedule-period.write.ts +2 -3
- package/src/ledger/handlers/reports.query.ts +2 -3
- package/src/ledger/handlers/reverse-transaction.write.ts +1 -2
- package/src/managed-pages/handlers/set.write.ts +13 -1
- package/src/notes-history/handlers/add-note.write.ts +17 -8
- package/src/personal-access-tokens/handlers/create.write.ts +2 -1
- package/src/secrets/handlers/list.query.ts +1 -3
- package/src/sessions/handlers/revoke-all-for-user.write.ts +9 -2
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +11 -1
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +10 -3
- package/src/tier-engine/feature.ts +11 -19
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +7 -6
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +6 -2
- package/src/user/__tests__/user-global-tenancy.integration.test.ts +97 -0
- package/src/user/handlers/update-roles.ts +14 -3
- package/src/user/handlers/update.write.ts +14 -1
- package/src/user/principal-status.ts +22 -1
- package/src/user/schema/user.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +3 -1
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +11 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +7 -5
- package/src/user-data-rights/handlers/deletion-grace-period.ts +11 -10
- package/src/user-data-rights/handlers/download-by-job.query.ts +17 -10
- package/src/user-data-rights/handlers/download-by-token.query.ts +21 -12
- package/src/user-data-rights/handlers/export-status.query.ts +8 -4
- package/src/user-data-rights/handlers/lift-restriction.write.ts +21 -9
- package/src/user-data-rights/handlers/my-audit-log.query.ts +7 -4
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +13 -8
- package/src/user-data-rights/handlers/request-deletion.write.ts +2 -1
- package/src/user-data-rights/handlers/request-export.write.ts +13 -8
- package/src/user-data-rights/handlers/restrict-account.write.ts +18 -13
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +9 -5
- package/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
- package/src/user-data-rights/run-forget-cleanup.ts +2 -2
- package/src/user-data-rights/schema/export-job.ts +2 -4
package/src/delivery/jobs.ts
CHANGED
|
@@ -51,16 +51,17 @@ function requireTenantScopedDeps(
|
|
|
51
51
|
): { db: DbConnection; registry: Registry } {
|
|
52
52
|
const registry = ctx.registry;
|
|
53
53
|
if (!registry) throw new Error("delivery job: missing registry in job context");
|
|
54
|
-
// delivery is r.systemScope()'d
|
|
55
|
-
//
|
|
56
|
-
// buildChannelContext the underlying DbConnection — safe because both
|
|
57
|
-
// filter by the same tenantId themselves (see JobContext.systemDb doc).
|
|
54
|
+
// delivery is r.systemScope()'d, so JobContext.systemDb is guaranteed; unsafeRaw
|
|
55
|
+
// hands the underlying DbConnection to both — safe since they filter by tenantId themselves.
|
|
58
56
|
if (!ctx.systemDb) {
|
|
59
57
|
throw new InternalError({
|
|
60
58
|
message: "delivery job: ctx.systemDb missing on a system-scoped job",
|
|
61
59
|
});
|
|
62
60
|
}
|
|
63
|
-
|
|
61
|
+
ctx.systemDb.assertTenantMatch(tenantId);
|
|
62
|
+
const db = ctx.systemDb.unsafeRaw(
|
|
63
|
+
"delivery attempt append and channel context filter by this job's tenantId themselves",
|
|
64
|
+
) as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
|
|
64
65
|
return { db, registry };
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
3
|
boolean,
|
|
4
|
+
declareGlobalTenancy,
|
|
4
5
|
type EntityTableMeta,
|
|
5
6
|
instant,
|
|
6
7
|
integer,
|
|
7
8
|
table as pgTable,
|
|
9
|
+
type SchemaTable,
|
|
8
10
|
sql,
|
|
9
11
|
text,
|
|
10
12
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
11
14
|
|
|
12
15
|
// Global feature-toggle override state. One row per feature that has ever
|
|
13
16
|
// been explicitly flipped by an operator. Missing row = "no override,
|
|
@@ -17,17 +20,18 @@ import {
|
|
|
17
20
|
// name IS the identity here. No tenantId: this is a global override that
|
|
18
21
|
// applies across every tenant (per-tenant toggles are intentionally out of
|
|
19
22
|
// scope, see core-feature-toggles.md).
|
|
20
|
-
export const globalFeatureStateTable
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
})
|
|
23
|
+
export const globalFeatureStateTable: SchemaTable & TenancyBrand<"global"> = declareGlobalTenancy(
|
|
24
|
+
pgTable("store_global_feature_state", {
|
|
25
|
+
featureName: text("feature_name").primaryKey(),
|
|
26
|
+
enabled: boolean("enabled").notNull(),
|
|
27
|
+
// Optimistic-lock column: the set-handler updates `WHERE feature_name = ? AND
|
|
28
|
+
// version = ?`; a 0-row update means a concurrent write, so it retries the fetch.
|
|
29
|
+
version: integer("version").notNull().default(1),
|
|
30
|
+
updatedAt: instant("updated_at").default(sql`now()`).notNull(),
|
|
31
|
+
// UserId (text — SessionUser.id is a uuid string post-ES).
|
|
32
|
+
updatedBy: text("updated_by"),
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
31
35
|
|
|
32
36
|
// r.storeTable meta — without this, collectTableMetas(FEATURES) never
|
|
33
37
|
// sees the table, so `kumiko schema generate` reports no changes and no
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
|
|
@@ -32,7 +31,7 @@ export const listQuery = defineQueryHandler({
|
|
|
32
31
|
updatedAt: Temporal.Instant;
|
|
33
32
|
updatedBy: string;
|
|
34
33
|
};
|
|
35
|
-
const rows = await selectMany<Row>(
|
|
34
|
+
const rows = await db.global(globalFeatureStateTable).selectMany<Row>();
|
|
36
35
|
return {
|
|
37
36
|
items: rows.map((r) => ({
|
|
38
37
|
featureName: r.featureName,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
|
|
@@ -36,7 +35,7 @@ export const registeredQuery = defineQueryHandler({
|
|
|
36
35
|
const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
|
|
37
36
|
|
|
38
37
|
type OverrideRow = { featureName: string; enabled: boolean };
|
|
39
|
-
const overrideRows = await selectMany<OverrideRow>(
|
|
38
|
+
const overrideRows = await db.global(globalFeatureStateTable).selectMany<OverrideRow>();
|
|
40
39
|
const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
|
|
41
40
|
|
|
42
41
|
// SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
|
|
@@ -17,6 +17,9 @@ const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
|
17
17
|
entityName: "fileRef",
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const DISCARD_DRAFT_FILE_REFS_REASON =
|
|
21
|
+
"matches draft storage keys against the caller's own file_refs rows via raw SQL";
|
|
22
|
+
|
|
20
23
|
// discard — the caller's own draft only. Ownership is enforced by the
|
|
21
24
|
// lookup predicate (tenantId + ownerId + draftKey), not by a separate
|
|
22
25
|
// permission check: a foreign user's discard call for someone else's
|
|
@@ -30,6 +33,9 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
30
33
|
description:
|
|
31
34
|
"Deletes the calling user's draft for one draftKey and, when releaseFiles is set, hard-deletes the file references that draft alone uploaded; use it after a successful submit or when the user abandons the form.",
|
|
32
35
|
agent: { risk: "high" },
|
|
36
|
+
escapeHatch: {
|
|
37
|
+
reason: DISCARD_DRAFT_FILE_REFS_REASON,
|
|
38
|
+
},
|
|
33
39
|
handler: async (event, ctx) => {
|
|
34
40
|
const ownerId = event.user.id;
|
|
35
41
|
const existing = await lookupDraft(
|
|
@@ -62,7 +68,7 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
62
68
|
// predates the draft row, so the insertedAt filter must not apply.
|
|
63
69
|
const isCreateMode = event.payload.draftKey.includes(":new:");
|
|
64
70
|
const ownedRefs = await filterOwnedFileRefs(
|
|
65
|
-
ctx.db.
|
|
71
|
+
ctx.db.unsafeRaw(DISCARD_DRAFT_FILE_REFS_REASON),
|
|
66
72
|
event.user.tenantId,
|
|
67
73
|
ownerId,
|
|
68
74
|
candidateKeys,
|
|
@@ -31,12 +31,18 @@ function isDraftKeyConflict(failure: WriteFailure): boolean {
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const SAVE_DRAFT_COUNT_REASON =
|
|
35
|
+
"COUNT(*) of the caller's own drafts without loading 64KB value blobs";
|
|
36
|
+
|
|
34
37
|
export const saveDraftWrite = defineWriteHandler({
|
|
35
38
|
name: "save",
|
|
36
39
|
schema: saveDraftPayloadSchema,
|
|
37
40
|
access: FORM_DRAFT_ACCESS,
|
|
38
41
|
description:
|
|
39
42
|
"Upserts the calling user's draft for one draftKey with the given form values and step index, stamping savedAt server-side and refusing a brand-new draft once the per-owner draft cap is reached; use it to persist an in-progress form before the real entity exists.",
|
|
43
|
+
escapeHatch: {
|
|
44
|
+
reason: SAVE_DRAFT_COUNT_REASON,
|
|
45
|
+
},
|
|
40
46
|
handler: async (event, ctx) => {
|
|
41
47
|
const ownerId = event.user.id;
|
|
42
48
|
const { draftKey, values, stepIndex } = event.payload;
|
|
@@ -55,7 +61,11 @@ export const saveDraftWrite = defineWriteHandler({
|
|
|
55
61
|
// Cap check only on the create path — an update never grows the number
|
|
56
62
|
// of drafts this owner has, so a user already at the limit can still
|
|
57
63
|
// keep saving their existing drafts.
|
|
58
|
-
const draftCount = await countDraftsByOwner(
|
|
64
|
+
const draftCount = await countDraftsByOwner(
|
|
65
|
+
ctx.db.unsafeRaw(SAVE_DRAFT_COUNT_REASON),
|
|
66
|
+
event.user.tenantId,
|
|
67
|
+
ownerId,
|
|
68
|
+
);
|
|
59
69
|
if (draftCount >= FORM_DRAFT_MAX_PER_OWNER) {
|
|
60
70
|
return failUnprocessable("draft_limit_reached", { limit: FORM_DRAFT_MAX_PER_OWNER });
|
|
61
71
|
}
|
|
@@ -396,7 +396,9 @@ describe("scenario 2d: ingest-message — thread-rollup retry exhaustion (#1229)
|
|
|
396
396
|
const accountId = await connectSharedAccount(admin);
|
|
397
397
|
const messageIdHeader = "exhaustion-root@example.com";
|
|
398
398
|
|
|
399
|
-
const tenantDb = createTenantDb(db, admin.tenantId, "system"
|
|
399
|
+
const tenantDb = createTenantDb(db, admin.tenantId, "system", undefined, undefined, undefined, {
|
|
400
|
+
unsafeRaw: { reason: "test: hand-built ctx driving ingestMessageHandler directly" },
|
|
401
|
+
});
|
|
400
402
|
let threadAppendAttempts = 0;
|
|
401
403
|
const ctx = {
|
|
402
404
|
db: tenantDb,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// SystemAdmin-only: Aufrufer ist der Watch/Sync-Supervisor bzw. der
|
|
21
21
|
// Poll-Cron mit programmatic SystemUser — nie ein Tenant-Request.
|
|
22
22
|
|
|
23
|
-
import { countWhere
|
|
23
|
+
import { countWhere } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
24
24
|
import {
|
|
25
25
|
configuredPiiSubjectKms,
|
|
26
26
|
encryptPiiFieldValues,
|
|
@@ -106,11 +106,16 @@ const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
|
|
|
106
106
|
// — 'inbm' as ASCII, keeps it disjoint from the framework's other fixed
|
|
107
107
|
// single-int advisory-lock keys (schema bootstrap, es-ops boot).
|
|
108
108
|
const THREAD_ROLLUP_LOCK_NAMESPACE = 0x696e626d;
|
|
109
|
+
const THREAD_ROLLUP_ADVISORY_LOCK_REASON =
|
|
110
|
+
"takes the per-thread advisory lock and counts the caller tenant's thread messages; TenantDb has no lock/count API";
|
|
109
111
|
export const ingestMessageHandler: WriteHandlerDef = {
|
|
110
112
|
name: "ingest-message",
|
|
111
113
|
schema: ingestMessageSchema,
|
|
112
114
|
access: { roles: ["SystemAdmin"] },
|
|
113
115
|
agent: { expose: false },
|
|
116
|
+
escapeHatch: {
|
|
117
|
+
reason: THREAD_ROLLUP_ADVISORY_LOCK_REASON,
|
|
118
|
+
},
|
|
114
119
|
handler: async (event, ctx) => {
|
|
115
120
|
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
116
121
|
const payload = event.payload as IngestMessagePayload;
|
|
@@ -125,7 +130,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
125
130
|
// Check ist billig und macht den Handler rebuild-robust falls
|
|
126
131
|
// store_mail_seen_messages je getruncated wird).
|
|
127
132
|
// ---------------------------------------------------------------
|
|
128
|
-
const seenRows = await
|
|
133
|
+
const seenRows = await ctx.db.selectMany(seenMessageTable, {
|
|
129
134
|
accountId: payload.accountId,
|
|
130
135
|
providerMessageId: payload.providerMessageId,
|
|
131
136
|
});
|
|
@@ -138,7 +143,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
138
143
|
const existingEvents = await ctx.loadAggregate(messageAggId);
|
|
139
144
|
if (existingEvents.length > 0) {
|
|
140
145
|
// Stream existiert, Anchor fehlte → Anchor nachziehen, dann raus.
|
|
141
|
-
await
|
|
146
|
+
await ctx.db.insertOne(seenMessageTable, {
|
|
142
147
|
tenantId,
|
|
143
148
|
accountId: payload.accountId,
|
|
144
149
|
providerMessageId: payload.providerMessageId,
|
|
@@ -267,7 +272,12 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
267
272
|
)
|
|
268
273
|
: threadPlainPii;
|
|
269
274
|
|
|
270
|
-
|
|
275
|
+
const threadRollupRunner = ctx.db.unsafeRaw(THREAD_ROLLUP_ADVISORY_LOCK_REASON);
|
|
276
|
+
await acquireNamespacedAdvisoryLock(
|
|
277
|
+
threadRollupRunner,
|
|
278
|
+
THREAD_ROLLUP_LOCK_NAMESPACE,
|
|
279
|
+
threadAggId,
|
|
280
|
+
);
|
|
271
281
|
|
|
272
282
|
let threadAppendOk = false;
|
|
273
283
|
for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
|
|
@@ -278,7 +288,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
278
288
|
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
279
289
|
const newLastAt =
|
|
280
290
|
payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
|
|
281
|
-
const messageCount = await countWhere(
|
|
291
|
+
const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
|
|
282
292
|
tenantId,
|
|
283
293
|
threadKey,
|
|
284
294
|
});
|
|
@@ -308,7 +318,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
308
318
|
// 6. Dedup-Anchor. Läuft in derselben TX wie die appends — stirbt
|
|
309
319
|
// der Handler, rollt alles zusammen zurück.
|
|
310
320
|
// ---------------------------------------------------------------
|
|
311
|
-
await
|
|
321
|
+
await ctx.db.insertOne(seenMessageTable, {
|
|
312
322
|
tenantId,
|
|
313
323
|
accountId: payload.accountId,
|
|
314
324
|
providerMessageId: payload.providerMessageId,
|
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
// selbst + TenantAdmin/SystemAdmin (Compliance-Sicht). Filter läuft
|
|
8
8
|
// in-memory nach dem tenant-scoped select — Volumen ist die Anzahl
|
|
9
9
|
// verbundener Postfächer eines Tenants (einstellig bis zweistellig).
|
|
10
|
-
|
|
11
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
10
|
import {
|
|
13
11
|
configuredPiiSubjectKms,
|
|
14
12
|
decryptPiiFieldValues,
|
|
@@ -28,7 +26,7 @@ export const listAccountsQuery: QueryHandlerDef = {
|
|
|
28
26
|
description:
|
|
29
27
|
"Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers; use it for the mail connect and settings views.",
|
|
30
28
|
handler: async (_query, ctx) => {
|
|
31
|
-
const allRows = await
|
|
29
|
+
const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
|
|
32
30
|
tenantId: ctx.user.tenantId,
|
|
33
31
|
});
|
|
34
32
|
const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
// das Inbox-Cockpit lädt begrenzte Zeiträume; ein `limit`-Feld ist
|
|
9
9
|
// vorwärtskompatibel ergänzbar ohne den QN zu brechen.
|
|
10
10
|
|
|
11
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
11
|
import {
|
|
13
12
|
configuredPiiSubjectKms,
|
|
14
13
|
decryptPiiFieldValues,
|
|
@@ -42,7 +41,7 @@ export const listMessagesQuery: QueryHandlerDef = {
|
|
|
42
41
|
if (payload.threadKey) filter["threadKey"] = payload.threadKey;
|
|
43
42
|
if (payload.scope) filter["scope"] = payload.scope;
|
|
44
43
|
|
|
45
|
-
const allRows = await
|
|
44
|
+
const allRows = await ctx.db.selectMany(inboundMessagesProjectionTable, filter);
|
|
46
45
|
// Scope-Sichtbarkeit (Plan Entscheidung 2): Messages persönlicher
|
|
47
46
|
// Postfächer nur für Owner + TenantAdmin/SystemAdmin.
|
|
48
47
|
const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
|
|
@@ -27,10 +27,10 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
|
|
|
27
27
|
"[jobs:projection-rebuild] ctx.registry missing — job context requires the registry.",
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
-
// Global rebuild by design (streams every tenant's events
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
const db = ctx.systemDb.
|
|
30
|
+
// Global rebuild by design (streams every tenant's events) — acknowledgeCrossTenant
|
|
31
|
+
// documents that instead of the previous implicit ctx.db cast.
|
|
32
|
+
ctx.systemDb.acknowledgeCrossTenant("global projection rebuild");
|
|
33
|
+
const db = ctx.systemDb.unsafeRaw("projection rebuild spans every tenant's rows") as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
|
|
34
34
|
const result = await rebuildProjection(projection, {
|
|
35
35
|
db,
|
|
36
36
|
registry: ctx.registry,
|
|
@@ -42,9 +42,12 @@ export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<v
|
|
|
42
42
|
"[jobs:reindex-entity] ctx.systemDb missing — the owning feature must declare r.systemScope().",
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
-
//
|
|
45
|
+
// unsafeRaw bypasses tenant filtering, safe here only because reindexEntity
|
|
46
46
|
// filters `WHERE tenant_id = $1` itself with this same tenantId.
|
|
47
|
-
|
|
47
|
+
ctx.systemDb.assertTenantMatch(tenantId);
|
|
48
|
+
const db = ctx.systemDb.unsafeRaw(
|
|
49
|
+
"reindexEntity filters WHERE tenant_id = $1 itself with this job's tenantId",
|
|
50
|
+
);
|
|
48
51
|
const result = await reindexEntity(db, ctx.registry, ctx.searchAdapter, entity, tenantId);
|
|
49
52
|
ctx.log?.info?.(
|
|
50
53
|
`[jobs:reindex-entity] tenant=${tenantId} reindexed ${entity}: ${result.indexedRows}/${result.scannedRows} rows indexed` +
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Regression for fw#2858: reports.query.ts's loadBooks() reads accounts and
|
|
2
|
+
// transactions via a raw DbRunner with an explicit `tenantId` where-clause.
|
|
3
|
+
// If that filter is ever dropped (e.g. a future refactor swaps back to an
|
|
4
|
+
// unfiltered runner), a tenant's report would silently aggregate every other
|
|
5
|
+
// tenant's books. Two tenants book distinct, non-cancelling amounts so a
|
|
6
|
+
// cross-tenant leak changes the report totals, not just adds zero-sum noise.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
|
+
import {
|
|
12
|
+
createTestUser,
|
|
13
|
+
setupTestStack,
|
|
14
|
+
type TestStack,
|
|
15
|
+
unsafeCreateEntityTable,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
+
import { type AccountType, LedgerHandlers, LedgerQueries } from "../constants";
|
|
18
|
+
import { accountEntity, scheduleEntity, transactionEntity } from "../entity";
|
|
19
|
+
import { createLedgerFeature } from "../feature";
|
|
20
|
+
import type { Posting } from "../schemas";
|
|
21
|
+
|
|
22
|
+
const ledgerFeature = createLedgerFeature();
|
|
23
|
+
|
|
24
|
+
let stack: TestStack;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
stack = await setupTestStack({ features: [ledgerFeature] });
|
|
28
|
+
await unsafeCreateEntityTable(stack.db, accountEntity);
|
|
29
|
+
await unsafeCreateEntityTable(stack.db, transactionEntity);
|
|
30
|
+
await unsafeCreateEntityTable(stack.db, scheduleEntity);
|
|
31
|
+
await createEventsTable(stack.db);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterAll(async () => {
|
|
35
|
+
await stack.cleanup();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
40
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
|
|
41
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
|
|
42
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_schedules");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const tenantA = createTestUser({ roles: ["TenantAdmin"] });
|
|
46
|
+
const tenantB = createTestUser({
|
|
47
|
+
roles: ["TenantAdmin"],
|
|
48
|
+
tenantId: "00000000-0000-4000-8000-0000000000bb",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function createAccount(
|
|
52
|
+
name: string,
|
|
53
|
+
type: AccountType,
|
|
54
|
+
user: typeof tenantA,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const acc = await stack.http.writeOk<{ id: string }>(
|
|
57
|
+
LedgerHandlers.createAccount,
|
|
58
|
+
{ name, type },
|
|
59
|
+
user,
|
|
60
|
+
);
|
|
61
|
+
return acc.id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function createTransaction(
|
|
65
|
+
lines: readonly Posting[],
|
|
66
|
+
opts: { date?: string; description?: string },
|
|
67
|
+
user: typeof tenantA,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await stack.http.writeOk(
|
|
70
|
+
LedgerHandlers.createTransaction,
|
|
71
|
+
{ date: opts.date ?? "2026-01-15", description: opts.description ?? "Entry", lines },
|
|
72
|
+
user,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function seedBooks(user: typeof tenantA, capital: number, income: number, expense: number) {
|
|
77
|
+
const bank = await createAccount("Bank", "asset", user);
|
|
78
|
+
const equity = await createAccount("Equity", "equity", user);
|
|
79
|
+
const rent = await createAccount("Rent", "income", user);
|
|
80
|
+
const cost = await createAccount("Cost", "expense", user);
|
|
81
|
+
await createTransaction(
|
|
82
|
+
[
|
|
83
|
+
{ accountId: bank, amount: capital },
|
|
84
|
+
{ accountId: equity, amount: -capital },
|
|
85
|
+
],
|
|
86
|
+
{ date: "2026-01-01", description: "Capital" },
|
|
87
|
+
user,
|
|
88
|
+
);
|
|
89
|
+
await createTransaction(
|
|
90
|
+
[
|
|
91
|
+
{ accountId: bank, amount: income },
|
|
92
|
+
{ accountId: rent, amount: -income },
|
|
93
|
+
],
|
|
94
|
+
{ date: "2026-01-10", description: "Income" },
|
|
95
|
+
user,
|
|
96
|
+
);
|
|
97
|
+
await createTransaction(
|
|
98
|
+
[
|
|
99
|
+
{ accountId: cost, amount: expense },
|
|
100
|
+
{ accountId: bank, amount: -expense },
|
|
101
|
+
],
|
|
102
|
+
{ date: "2026-01-20", description: "Expense" },
|
|
103
|
+
user,
|
|
104
|
+
);
|
|
105
|
+
return { bank };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("ledger integration — report tenant isolation", () => {
|
|
109
|
+
test("reportBalances excludes the other tenant's accounts and amounts", async () => {
|
|
110
|
+
const { bank: bankA } = await seedBooks(tenantA, 500000, 100000, 30000);
|
|
111
|
+
const { bank: bankB } = await seedBooks(tenantB, 200000, 50000, 10000);
|
|
112
|
+
|
|
113
|
+
const reportA = await stack.http.queryOk<{
|
|
114
|
+
accounts: Array<{ id: string; balance: number }>;
|
|
115
|
+
trialBalance: number;
|
|
116
|
+
}>(LedgerQueries.reportBalances, {}, tenantA);
|
|
117
|
+
expect(reportA.accounts).toHaveLength(4);
|
|
118
|
+
expect(reportA.accounts.some((a) => a.id === bankB)).toBe(false);
|
|
119
|
+
expect(reportA.accounts.find((a) => a.id === bankA)?.balance).toBe(570000);
|
|
120
|
+
|
|
121
|
+
const reportB = await stack.http.queryOk<{
|
|
122
|
+
accounts: Array<{ id: string; balance: number }>;
|
|
123
|
+
trialBalance: number;
|
|
124
|
+
}>(LedgerQueries.reportBalances, {}, tenantB);
|
|
125
|
+
expect(reportB.accounts).toHaveLength(4);
|
|
126
|
+
expect(reportB.accounts.some((a) => a.id === bankA)).toBe(false);
|
|
127
|
+
expect(reportB.accounts.find((a) => a.id === bankB)?.balance).toBe(240000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("reportIncomeStatement does not sum the other tenant's income/expense", async () => {
|
|
131
|
+
await seedBooks(tenantA, 500000, 100000, 30000);
|
|
132
|
+
await seedBooks(tenantB, 200000, 50000, 10000);
|
|
133
|
+
|
|
134
|
+
const stmtA = await stack.http.queryOk<{
|
|
135
|
+
income: number;
|
|
136
|
+
expense: number;
|
|
137
|
+
netIncome: number;
|
|
138
|
+
}>(LedgerQueries.reportIncomeStatement, {}, tenantA);
|
|
139
|
+
expect(stmtA).toEqual({ income: 100000, expense: 30000, netIncome: 70000 });
|
|
140
|
+
|
|
141
|
+
const stmtB = await stack.http.queryOk<{
|
|
142
|
+
income: number;
|
|
143
|
+
expense: number;
|
|
144
|
+
netIncome: number;
|
|
145
|
+
}>(LedgerQueries.reportIncomeStatement, {}, tenantB);
|
|
146
|
+
expect(stmtB).toEqual({ income: 50000, expense: 10000, netIncome: 40000 });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import type {
|
|
3
2
|
AccessRule,
|
|
4
3
|
WriteHandlerDef,
|
|
@@ -68,14 +67,14 @@ export function createConfirmSchedulePeriodHandler(
|
|
|
68
67
|
// ponytail: read-then-write, so two confirms racing the same period could
|
|
69
68
|
// double-book; add a unique index on (tenantId, reference) when concurrent
|
|
70
69
|
// confirms become real.
|
|
71
|
-
const candidates = await
|
|
70
|
+
const candidates = await ctx.db.selectMany(transactionTable, {
|
|
72
71
|
tenantId: event.user.tenantId,
|
|
73
72
|
reference,
|
|
74
73
|
});
|
|
75
74
|
const candidateIds = candidates.map((r) => String(r["id"]));
|
|
76
75
|
const stornos =
|
|
77
76
|
candidateIds.length > 0
|
|
78
|
-
? await
|
|
77
|
+
? await ctx.db.selectMany(transactionTable, {
|
|
79
78
|
tenantId: event.user.tenantId,
|
|
80
79
|
reference: { in: candidateIds },
|
|
81
80
|
})
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import type { AccessRule, QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { DEFAULT_LEDGER_ACCESS } from "../constants";
|
|
@@ -25,8 +24,8 @@ type QueryCtx = Parameters<QueryHandlerDef["handler"]>[1];
|
|
|
25
24
|
async function loadBooks(
|
|
26
25
|
ctx: QueryCtx,
|
|
27
26
|
): Promise<{ accounts: LedgerAccount[]; entries: LedgerEntry[] }> {
|
|
28
|
-
const accountRows = await
|
|
29
|
-
const txRows = await
|
|
27
|
+
const accountRows = await ctx.db.selectMany(accountTable, { tenantId: ctx.user.tenantId });
|
|
28
|
+
const txRows = await ctx.db.selectMany(transactionTable, { tenantId: ctx.user.tenantId });
|
|
30
29
|
return { accounts: toAccounts(accountRows), entries: toEntries(txRows) };
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import {
|
|
4
3
|
ConflictError,
|
|
@@ -48,7 +47,7 @@ export function createReverseTransactionHandler(
|
|
|
48
47
|
// an independently-balanced Storno (the global trial balance stays 0
|
|
49
48
|
// either way, masking the bug), doubling the per-account effect. The
|
|
50
49
|
// reference column ties a Storno back to its original 1:1.
|
|
51
|
-
const alreadyReversed = await
|
|
50
|
+
const alreadyReversed = await ctx.db.selectMany(transactionTable, {
|
|
52
51
|
tenantId: event.user.tenantId,
|
|
53
52
|
reference: payload.id,
|
|
54
53
|
});
|
|
@@ -23,6 +23,9 @@ const langSchema = z
|
|
|
23
23
|
|
|
24
24
|
const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
|
|
25
25
|
|
|
26
|
+
const SET_PAGE_TENANT_OVERRIDE_REASON =
|
|
27
|
+
"SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
|
|
28
|
+
|
|
26
29
|
// Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
|
|
27
30
|
// Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
|
|
28
31
|
// für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
|
|
@@ -45,6 +48,9 @@ export const setWrite = defineWriteHandler({
|
|
|
45
48
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
46
49
|
description:
|
|
47
50
|
"Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
|
|
51
|
+
escapeHatch: {
|
|
52
|
+
reason: SET_PAGE_TENANT_OVERRIDE_REASON,
|
|
53
|
+
},
|
|
48
54
|
handler: async (event, ctx) => {
|
|
49
55
|
const db = ctx.db;
|
|
50
56
|
const override = event.payload.tenantIdOverride;
|
|
@@ -63,7 +69,13 @@ export const setWrite = defineWriteHandler({
|
|
|
63
69
|
// on both the existing-check and the executor's stream reads; re-scope to the target.
|
|
64
70
|
// Safe: the override branch is SystemAdmin-gated above.
|
|
65
71
|
const scopedDb =
|
|
66
|
-
override !== undefined
|
|
72
|
+
override !== undefined
|
|
73
|
+
? createTenantDb(
|
|
74
|
+
db.unsafeRaw(SET_PAGE_TENANT_OVERRIDE_REASON),
|
|
75
|
+
override as TenantId, // @cast-boundary engine-bridge
|
|
76
|
+
"tenant",
|
|
77
|
+
)
|
|
78
|
+
: db;
|
|
67
79
|
const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
|
|
68
80
|
tenantId,
|
|
69
81
|
slug: event.payload.slug,
|
|
@@ -7,6 +7,9 @@ import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
|
7
7
|
import { noteEntryExecutor, noteMentionExecutor } from "../executor";
|
|
8
8
|
import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
9
9
|
|
|
10
|
+
const READ_AUTHOR_DISPLAY_NAME_REASON =
|
|
11
|
+
"reads the author's displayName from the global users table inside a savepoint so a missing user feature cannot poison the note transaction";
|
|
12
|
+
|
|
10
13
|
// add-note — appends a note-entry to (entityType, entityId). authorId is
|
|
11
14
|
// NEVER read from the payload: it is always the authenticated caller
|
|
12
15
|
// (event.user.id), so a note can't be authored as someone else. No update or
|
|
@@ -28,6 +31,9 @@ export function createAddNoteHandler(
|
|
|
28
31
|
access,
|
|
29
32
|
description:
|
|
30
33
|
"Appends a note to one host entity's history, stamping the author from the authenticated caller rather than the payload; use it for every remark and correction alike, because entries can never be edited or removed afterwards.",
|
|
34
|
+
escapeHatch: {
|
|
35
|
+
reason: READ_AUTHOR_DISPLAY_NAME_REASON,
|
|
36
|
+
},
|
|
31
37
|
handler: async (event, ctx) => {
|
|
32
38
|
const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
|
|
33
39
|
|
|
@@ -47,16 +53,19 @@ export function createAddNoteHandler(
|
|
|
47
53
|
|
|
48
54
|
let authorName: string | null = null;
|
|
49
55
|
try {
|
|
50
|
-
// read_users is tenant-agnostic → ctx.db.
|
|
56
|
+
// read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
|
|
51
57
|
// Bun.SQL poisons the whole tx after any error inside it, even one that's
|
|
52
58
|
// caught — a bare try/catch here would take the note write down with it.
|
|
53
|
-
authorName = await runInSavepointIfSupported(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
authorName = await runInSavepointIfSupported(
|
|
60
|
+
ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON),
|
|
61
|
+
async (sp) => {
|
|
62
|
+
const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
|
|
63
|
+
id: event.user.id,
|
|
64
|
+
});
|
|
65
|
+
if (!userRow?.displayName) return null;
|
|
66
|
+
return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
|
|
67
|
+
},
|
|
68
|
+
);
|
|
60
69
|
} catch (e) {
|
|
61
70
|
ctx.log?.warn("notes-history: authorName lookup failed", {
|
|
62
71
|
error: e,
|
|
@@ -61,7 +61,8 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
|
|
|
61
61
|
escapeHatch: {
|
|
62
62
|
reason:
|
|
63
63
|
"Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
|
|
64
|
-
"user:findForAuth) to re-verify the current password before minting the token."
|
|
64
|
+
"user:findForAuth) to re-verify the current password before minting the token. Also " +
|
|
65
|
+
"reads the MFA enrollment of the user being re-authenticated via the mfaVerifier callback.",
|
|
65
66
|
},
|
|
66
67
|
description:
|
|
67
68
|
"Mints a personal access token for the calling user after re-verifying their password (and MFA code when enrolled) and returns the plaintext token exactly once; use it when a user needs a long-lived API credential.",
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { type AccessRule, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { DEFAULT_SECRETS_ACCESS } from "../constants";
|
|
@@ -15,14 +14,13 @@ export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
|
15
14
|
schema: z.object({}),
|
|
16
15
|
access,
|
|
17
16
|
handler: async (event, ctx) => {
|
|
18
|
-
const rows = await selectMany<{
|
|
17
|
+
const rows = await ctx.db.selectMany<{
|
|
19
18
|
key: string;
|
|
20
19
|
kekVersion: number;
|
|
21
20
|
metadata: { redactedPreview?: string; hint?: string };
|
|
22
21
|
lastRotatedAt: unknown;
|
|
23
22
|
insertedAt: unknown;
|
|
24
23
|
}>(
|
|
25
|
-
ctx.db.raw,
|
|
26
24
|
tenantSecretsTable,
|
|
27
25
|
{ tenantId: event.user.tenantId },
|
|
28
26
|
{
|