@cosmicdrift/kumiko-bundled-features 0.277.0 → 0.280.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__/invite-flow-kms.integration.test.ts +8 -2
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
- package/src/auth-mfa-user-data/hooks.ts +4 -6
- package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -10
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +123 -6
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +135 -1
- package/src/cap-counter/book-cap-usage.ts +158 -0
- package/src/cap-counter/changes.json +16 -1
- package/src/cap-counter/enforce-cap.ts +5 -37
- package/src/cap-counter/handlers/increment.write.ts +7 -47
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +6 -33
- package/src/cap-counter/index.ts +8 -0
- package/src/cap-counter/stock-cap-guard.ts +14 -19
- package/src/cap-counter/with-cap-enforcement.ts +9 -11
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
- package/src/custom-fields/wire-user-data-rights.ts +40 -24
- package/src/data-retention/resolve-for-tenant.ts +18 -6
- package/src/delivery/db/queries/preferences.ts +1 -0
- package/src/files-tenant-data/hooks.ts +3 -5
- package/src/folders/__tests__/folders.integration.test.ts +4 -3
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
- package/src/folders-user-data/hooks.ts +5 -10
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
- package/src/form-draft-user-data/hooks.ts +3 -6
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
- package/src/notes-history-user-data/hooks.ts +3 -4
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/src/template-resolver-user-data/hooks.ts +1 -2
- package/src/tenant-lifecycle/feature.ts +2 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
- package/src/tenant-lifecycle/stages.ts +26 -2
- package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +138 -0
- package/src/tier-engine/changes.json +7 -0
- package/src/tier-engine/tier-resolver.ts +7 -6
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
- package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
- package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
- package/src/user-data-rights/feature.ts +4 -0
- package/src/user-data-rights/run-export-jobs.ts +16 -1
- package/src/user-data-rights/run-forget-cleanup.ts +44 -2
- package/src/user-data-rights/run-user-export.ts +43 -3
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
11
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
12
|
import {
|
|
12
13
|
createTestUser,
|
|
@@ -50,7 +51,12 @@ describe("formDraftDeleteHook — delete failure handling (#review-batch-fw-1)",
|
|
|
50
51
|
|
|
51
52
|
await expect(
|
|
52
53
|
formDraftDeleteHook(
|
|
53
|
-
{
|
|
54
|
+
{
|
|
55
|
+
db: createTenantDb(stack.db, bob.tenantId, "tenant"),
|
|
56
|
+
registry: stack.registry,
|
|
57
|
+
tenantId: bob.tenantId,
|
|
58
|
+
userId: bob.id,
|
|
59
|
+
},
|
|
54
60
|
"delete",
|
|
55
61
|
),
|
|
56
62
|
).resolves.toBeUndefined();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
9
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
10
|
import {
|
|
10
11
|
createTestUser,
|
|
@@ -52,7 +53,7 @@ describe("formDraftExportHook", () => {
|
|
|
52
53
|
);
|
|
53
54
|
|
|
54
55
|
const snippet = await formDraftExportHook({
|
|
55
|
-
db: stack.db,
|
|
56
|
+
db: createTenantDb(stack.db, owner.tenantId, "tenant"),
|
|
56
57
|
registry: stack.registry,
|
|
57
58
|
tenantId: owner.tenantId,
|
|
58
59
|
userId: owner.id,
|
|
@@ -88,7 +89,7 @@ describe("formDraftExportHook", () => {
|
|
|
88
89
|
);
|
|
89
90
|
|
|
90
91
|
const snippet = await formDraftExportHook({
|
|
91
|
-
db: stack.db,
|
|
92
|
+
db: createTenantDb(stack.db, tenantATestTenantId, "tenant"),
|
|
92
93
|
registry: stack.registry,
|
|
93
94
|
tenantId: tenantATestTenantId,
|
|
94
95
|
userId: sameIdTenantA.id,
|
|
@@ -101,7 +102,7 @@ describe("formDraftExportHook", () => {
|
|
|
101
102
|
test("returns null when the user saved no drafts", async () => {
|
|
102
103
|
const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
|
|
103
104
|
const snippet = await formDraftExportHook({
|
|
104
|
-
db: stack.db,
|
|
105
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
105
106
|
registry: stack.registry,
|
|
106
107
|
tenantId: lurker.tenantId,
|
|
107
108
|
userId: lurker.id,
|
|
@@ -126,7 +127,12 @@ describe("formDraftDeleteHook", () => {
|
|
|
126
127
|
);
|
|
127
128
|
|
|
128
129
|
await formDraftDeleteHook(
|
|
129
|
-
{
|
|
130
|
+
{
|
|
131
|
+
db: createTenantDb(stack.db, alice.tenantId, "tenant"),
|
|
132
|
+
registry: stack.registry,
|
|
133
|
+
tenantId: alice.tenantId,
|
|
134
|
+
userId: alice.id,
|
|
135
|
+
},
|
|
130
136
|
"delete",
|
|
131
137
|
);
|
|
132
138
|
|
|
@@ -138,7 +144,12 @@ describe("formDraftDeleteHook", () => {
|
|
|
138
144
|
const lurker = createTestUser({ id: 13, roles: ["TenantMember"] });
|
|
139
145
|
await expect(
|
|
140
146
|
formDraftDeleteHook(
|
|
141
|
-
{
|
|
147
|
+
{
|
|
148
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
149
|
+
registry: stack.registry,
|
|
150
|
+
tenantId: lurker.tenantId,
|
|
151
|
+
userId: lurker.id,
|
|
152
|
+
},
|
|
142
153
|
"delete",
|
|
143
154
|
),
|
|
144
155
|
).resolves.toBeUndefined();
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
// consumers without the user-data-rights pipeline don't pull a hard
|
|
4
4
|
// dependency.
|
|
5
5
|
|
|
6
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
-
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
6
|
import {
|
|
9
7
|
createSystemUser,
|
|
10
8
|
type UserDataDeleteHook,
|
|
@@ -13,7 +11,7 @@ import {
|
|
|
13
11
|
import { formDraftExecutor, formDraftTable } from "../form-draft";
|
|
14
12
|
|
|
15
13
|
export const formDraftExportHook: UserDataExportHook = async (ctx) => {
|
|
16
|
-
const rows = await
|
|
14
|
+
const rows = await ctx.db.selectMany(formDraftTable, {
|
|
17
15
|
tenantId: ctx.tenantId,
|
|
18
16
|
ownerId: ctx.userId,
|
|
19
17
|
});
|
|
@@ -37,15 +35,14 @@ export const formDraftExportHook: UserDataExportHook = async (ctx) => {
|
|
|
37
35
|
// owners' drafts, tenant model doesn't matter here (unlike folders' single-
|
|
38
36
|
// user gating: a form-draft row always has exactly one owner already).
|
|
39
37
|
export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
40
|
-
const rows = await selectMany<{ id: string }>(
|
|
38
|
+
const rows = await ctx.db.selectMany<{ id: string }>(formDraftTable, {
|
|
41
39
|
tenantId: ctx.tenantId,
|
|
42
40
|
ownerId: ctx.userId,
|
|
43
41
|
});
|
|
44
42
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
45
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
46
43
|
const failures: string[] = [];
|
|
47
44
|
for (const row of rows) {
|
|
48
|
-
const result = await formDraftExecutor.delete({ id: row.id }, systemUser,
|
|
45
|
+
const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
|
|
49
46
|
if (!result.isSuccess) {
|
|
50
47
|
failures.push(`${row.id}: ${result.error.message}`);
|
|
51
48
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
9
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
10
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
11
|
import {
|
|
@@ -78,7 +79,7 @@ describe("noteEntryExportHook", () => {
|
|
|
78
79
|
);
|
|
79
80
|
|
|
80
81
|
const snippet = await noteEntryExportHook({
|
|
81
|
-
db: stack.db,
|
|
82
|
+
db: createTenantDb(stack.db, author.tenantId, "tenant"),
|
|
82
83
|
registry: stack.registry,
|
|
83
84
|
tenantId: author.tenantId,
|
|
84
85
|
userId: author.id,
|
|
@@ -95,7 +96,7 @@ describe("noteEntryExportHook", () => {
|
|
|
95
96
|
test("returns null when the user authored no notes", async () => {
|
|
96
97
|
const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
|
|
97
98
|
const snippet = await noteEntryExportHook({
|
|
98
|
-
db: stack.db,
|
|
99
|
+
db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
|
|
99
100
|
registry: stack.registry,
|
|
100
101
|
tenantId: lurker.tenantId,
|
|
101
102
|
userId: lurker.id,
|
|
@@ -107,7 +108,12 @@ describe("noteEntryExportHook", () => {
|
|
|
107
108
|
describe("noteEntryDeleteHook", () => {
|
|
108
109
|
test("is a no-op — resolves without throwing", async () => {
|
|
109
110
|
const result = await noteEntryDeleteHook(
|
|
110
|
-
{
|
|
111
|
+
{
|
|
112
|
+
db: createTenantDb(stack.db, author.tenantId, "tenant"),
|
|
113
|
+
registry: stack.registry,
|
|
114
|
+
tenantId: author.tenantId,
|
|
115
|
+
userId: author.id,
|
|
116
|
+
},
|
|
111
117
|
"delete",
|
|
112
118
|
);
|
|
113
119
|
expect(result).toBeUndefined();
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
|
|
5
5
|
// export-only, erasure via crypto-shredding.
|
|
6
6
|
|
|
7
|
-
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
7
|
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
9
8
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
9
|
import { resolveRetentionPolicyForTenant } from "../data-retention";
|
|
@@ -14,7 +13,7 @@ import { policyToStrategy } from "../user-data-rights";
|
|
|
14
13
|
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
15
14
|
// per-user content, so the export filters by authorId directly.
|
|
16
15
|
export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
17
|
-
const rows = await
|
|
16
|
+
const rows = await ctx.db.selectMany(noteEntryTable, {
|
|
18
17
|
authorId: ctx.userId,
|
|
19
18
|
});
|
|
20
19
|
if (rows.length === 0) return null;
|
|
@@ -55,7 +54,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
55
54
|
// skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
|
|
56
55
|
if (!kms) return;
|
|
57
56
|
|
|
58
|
-
const mentions = await selectMany<{ noteId: string }>(
|
|
57
|
+
const mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
|
|
59
58
|
subjectId: ctx.userId,
|
|
60
59
|
});
|
|
61
60
|
// skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
|
|
@@ -63,7 +62,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
|
63
62
|
|
|
64
63
|
const noteIds = new Set(mentions.map((m) => m.noteId));
|
|
65
64
|
for (const noteId of noteIds) {
|
|
66
|
-
const note = await fetchOne<{ entityType: string }>(
|
|
65
|
+
const note = await ctx.db.fetchOne<{ entityType: string }>(noteEntryTable, { id: noteId });
|
|
67
66
|
// Defensive: append-only rows are never hard-deleted, so this shouldn't
|
|
68
67
|
// happen — but a missing host means no retention policy to consult.
|
|
69
68
|
if (!note) continue;
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// so apps without the user-data-rights pipeline don't pull a hard dependency.
|
|
4
4
|
// Mirrors notes-history-user-data: export-only, erasure via crypto-shredding.
|
|
5
5
|
|
|
6
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
6
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
7
|
import { userContentEntriesTable } from "../template-resolver";
|
|
9
8
|
|
|
@@ -11,7 +10,7 @@ import { userContentEntriesTable } from "../template-resolver";
|
|
|
11
10
|
// scoping comes from the tenant-scoped ctx.db; a user who belongs to two
|
|
12
11
|
// tenants gets each tenant's entries from that tenant's forget run.
|
|
13
12
|
export const userContentExportHook: UserDataExportHook = async (ctx) => {
|
|
14
|
-
const rows = await
|
|
13
|
+
const rows = await ctx.db.selectMany(userContentEntriesTable, {
|
|
15
14
|
ownerId: ctx.userId,
|
|
16
15
|
});
|
|
17
16
|
if (rows.length === 0) return null;
|
|
@@ -132,6 +132,8 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
132
132
|
now: T.Now.instant(),
|
|
133
133
|
log: (message) => ctx.log?.warn(message),
|
|
134
134
|
fileProviderResolver: ctx._fileProviderResolver,
|
|
135
|
+
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
136
|
+
actor: ctx.systemUser.id,
|
|
135
137
|
});
|
|
136
138
|
},
|
|
137
139
|
});
|
|
@@ -3,6 +3,7 @@ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
|
3
3
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import {
|
|
5
5
|
createSystemUser,
|
|
6
|
+
type EscapeHatchAuditSink,
|
|
6
7
|
type Registry,
|
|
7
8
|
type TenantId,
|
|
8
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -216,6 +217,8 @@ export async function runNextDestructionStage(args: {
|
|
|
216
217
|
readonly tenantId: TenantId;
|
|
217
218
|
readonly log?: (message: string) => void;
|
|
218
219
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
220
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
221
|
+
readonly actor?: string;
|
|
219
222
|
}): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
|
|
220
223
|
const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
221
224
|
const { completed, abandoned, attemptsByStage } = replayStageState(events);
|
|
@@ -238,6 +241,8 @@ export async function runNextDestructionStage(args: {
|
|
|
238
241
|
tenantId: args.tenantId,
|
|
239
242
|
log: args.log,
|
|
240
243
|
fileProviderResolver: args.fileProviderResolver,
|
|
244
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
245
|
+
actor: args.actor,
|
|
241
246
|
};
|
|
242
247
|
|
|
243
248
|
let version = lastEventVersion(events);
|
|
@@ -304,6 +309,8 @@ export async function runTenantDestructionSweep(args: {
|
|
|
304
309
|
readonly now?: Temporal.Instant;
|
|
305
310
|
readonly log?: (message: string) => void;
|
|
306
311
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
312
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
313
|
+
readonly actor?: string;
|
|
307
314
|
}): Promise<{ readonly triggered: number; readonly advanced: number }> {
|
|
308
315
|
const T = getTemporal();
|
|
309
316
|
const now = args.now ?? T.Now.instant();
|
|
@@ -353,6 +360,8 @@ export async function runTenantDestructionSweep(args: {
|
|
|
353
360
|
tenantId: row.id as TenantId,
|
|
354
361
|
log: args.log,
|
|
355
362
|
fileProviderResolver: args.fileProviderResolver,
|
|
363
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
364
|
+
actor: args.actor,
|
|
356
365
|
});
|
|
357
366
|
if (!result.error && !result.halted) advanced++;
|
|
358
367
|
} catch (err) {
|
|
@@ -7,15 +7,19 @@ import {
|
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
8
8
|
import {
|
|
9
9
|
createSystemUser,
|
|
10
|
+
type EscapeHatchAuditSink,
|
|
10
11
|
EXT_EXTERNAL_RESOURCE,
|
|
11
12
|
EXT_INFRA_RESOURCE,
|
|
12
13
|
EXT_SEARCH_ADAPTER,
|
|
13
14
|
EXT_STORAGE_PROVIDER,
|
|
14
15
|
EXT_TENANT_DATA,
|
|
16
|
+
extensionUsageEscapeHatchReason,
|
|
15
17
|
type Registry,
|
|
18
|
+
type TenantDataHookCtx,
|
|
16
19
|
type TenantId,
|
|
17
20
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
21
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
22
|
+
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
19
23
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
20
24
|
import {
|
|
21
25
|
tenantEntity,
|
|
@@ -35,6 +39,11 @@ export type DestructionStageCtx = {
|
|
|
35
39
|
// "destroyTenant" hooks) — undefined when no file-provider is wired,
|
|
36
40
|
// which those hooks must treat as "nothing to clean up", not an error.
|
|
37
41
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
42
|
+
// fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
|
|
43
|
+
// systemUser.id); runTenantDataHooks uses them to attribute+audit any
|
|
44
|
+
// EXT_TENANT_DATA usage's declared escapeHatch.
|
|
45
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
46
|
+
readonly actor?: string;
|
|
38
47
|
};
|
|
39
48
|
|
|
40
49
|
export type DestructionStage = {
|
|
@@ -73,10 +82,25 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
|
|
|
73
82
|
const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
|
|
74
83
|
for (const usage of usages) {
|
|
75
84
|
const destroy = usage.options?.["destroy"] as
|
|
76
|
-
| ((
|
|
85
|
+
| ((hookCtx: TenantDataHookCtx) => Promise<void>)
|
|
77
86
|
| undefined;
|
|
78
87
|
if (!destroy) continue;
|
|
79
|
-
|
|
88
|
+
const reason = extensionUsageEscapeHatchReason(usage);
|
|
89
|
+
const report = createEscapeHatchReporter({
|
|
90
|
+
handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
|
|
91
|
+
tenantId: ctx.tenantId,
|
|
92
|
+
actor: ctx.actor ?? "system",
|
|
93
|
+
sink: ctx.escapeHatchAuditSink,
|
|
94
|
+
});
|
|
95
|
+
const hookCtx: TenantDataHookCtx = {
|
|
96
|
+
db: createTenantDb(ctx.db, ctx.tenantId, "tenant", undefined, undefined, undefined, {
|
|
97
|
+
unsafeRaw: reason !== undefined ? { reason } : undefined,
|
|
98
|
+
report,
|
|
99
|
+
}),
|
|
100
|
+
registry: ctx.registry,
|
|
101
|
+
tenantId: ctx.tenantId,
|
|
102
|
+
};
|
|
103
|
+
await destroy(hookCtx);
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// createTierResolver (fw#2854): resolveTier/resolveTierCaps take only a
|
|
2
|
+
// TenantDb — the tenant comes from db.tenantId, so a caller can't request a
|
|
3
|
+
// foreign tenant's tier. Proves defaultTier for an unassigned tenant, the
|
|
4
|
+
// assigned tier for an assigned one, and the same behavior end-to-end
|
|
5
|
+
// through a TenantAdmin-only query handler with no escapeHatch.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { configValuesTable } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
9
|
+
import { tenantSecretsTable } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
10
|
+
import { tenantMembershipsTable, tenantTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
11
|
+
import { userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
12
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import { buildEntityTable, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
+
import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import {
|
|
16
|
+
createTestUser,
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
unsafePushTables,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { tierAssignmentEntity } from "../entity";
|
|
24
|
+
import { tierEngineFeature } from "../feature";
|
|
25
|
+
import { createTierResolver } from "../tier-resolver";
|
|
26
|
+
|
|
27
|
+
type TestCaps = { readonly maxItems: number };
|
|
28
|
+
type TestTier = "free" | "pro";
|
|
29
|
+
|
|
30
|
+
function isTestTierName(value: string): value is TestTier {
|
|
31
|
+
return value === "free" || value === "pro";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function capsForTier(tier: TestTier): TestCaps {
|
|
35
|
+
return tier === "pro" ? { maxItems: 5 } : { maxItems: 1 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { resolveTier, resolveTierCaps } = createTierResolver<TestTier, TestCaps>({
|
|
39
|
+
capsForTier,
|
|
40
|
+
isTierName: isTestTierName,
|
|
41
|
+
defaultTier: "free",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const RESOLVED_CAPS_QN = "resolver-probe:query:resolved-caps";
|
|
45
|
+
const resolverProbeFeature = defineFeature("resolver-probe", (r) => {
|
|
46
|
+
r.queryHandler({
|
|
47
|
+
name: "resolved-caps",
|
|
48
|
+
schema: z.object({}),
|
|
49
|
+
access: { roles: ["TenantAdmin"] },
|
|
50
|
+
handler: async (_query, ctx) => resolveTierCaps(ctx.db),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const features = composeFeatures([tierEngineFeature, resolverProbeFeature], {
|
|
55
|
+
includeBundled: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Same underlying table createTierResolver builds internally — pushed here
|
|
59
|
+
// so the schema exists before the write-handler + resolver read it.
|
|
60
|
+
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
61
|
+
|
|
62
|
+
let stack: TestStack;
|
|
63
|
+
const tenantA = "00000000-0000-4000-8000-0000000000c1" as TenantId;
|
|
64
|
+
const tenantB = "00000000-0000-4000-8000-0000000000c2" as TenantId;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
stack = await setupTestStack({ features });
|
|
68
|
+
await unsafePushTables(stack.db, {
|
|
69
|
+
config_values: configValuesTable,
|
|
70
|
+
users: userTable,
|
|
71
|
+
tenants: tenantTable,
|
|
72
|
+
tenant_memberships: tenantMembershipsTable,
|
|
73
|
+
tenant_secrets: tenantSecretsTable,
|
|
74
|
+
tier_assignments: tierAssignmentTable,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => stack?.cleanup());
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
await asRawClient(stack.db).unsafe(`TRUNCATE read_tier_assignments RESTART IDENTITY CASCADE`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function sysAdminFor(tenantId: TenantId) {
|
|
85
|
+
return createTestUser({ id: `sys-${tenantId}`, tenantId, roles: ["SystemAdmin", "TenantAdmin"] });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tenantAdminOnlyFor(tenantId: TenantId) {
|
|
89
|
+
return createTestUser({ id: `admin-${tenantId}`, tenantId, roles: ["TenantAdmin"] });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function assignTier(tenantId: TenantId, tier: TestTier): Promise<void> {
|
|
93
|
+
await stack.http.writeOk(
|
|
94
|
+
"tier-engine:write:tier-assignment:create",
|
|
95
|
+
{ tier },
|
|
96
|
+
sysAdminFor(tenantId),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("createTierResolver — resolveTier/resolveTierCaps", () => {
|
|
101
|
+
test("unassigned tenant B resolves to defaultTier, not tenant A's assigned tier", async () => {
|
|
102
|
+
await assignTier(tenantA, "pro");
|
|
103
|
+
|
|
104
|
+
const tierA = await resolveTier(createTenantDb(stack.db, tenantA));
|
|
105
|
+
expect(tierA).toBe("pro");
|
|
106
|
+
|
|
107
|
+
const tierB = await resolveTier(createTenantDb(stack.db, tenantB));
|
|
108
|
+
expect(tierB).toBe("free");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("resolveTierCaps maps the resolved tier via capsForTier", async () => {
|
|
112
|
+
await assignTier(tenantA, "pro");
|
|
113
|
+
|
|
114
|
+
const capsA = await resolveTierCaps(createTenantDb(stack.db, tenantA));
|
|
115
|
+
expect(capsA).toEqual({ maxItems: 5 });
|
|
116
|
+
|
|
117
|
+
const capsB = await resolveTierCaps(createTenantDb(stack.db, tenantB));
|
|
118
|
+
expect(capsB).toEqual({ maxItems: 1 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("end-to-end via a TenantAdmin-only query handler (no escapeHatch)", async () => {
|
|
122
|
+
await assignTier(tenantA, "pro");
|
|
123
|
+
|
|
124
|
+
const capsA = await stack.http.queryOk<TestCaps>(
|
|
125
|
+
RESOLVED_CAPS_QN,
|
|
126
|
+
{},
|
|
127
|
+
tenantAdminOnlyFor(tenantA),
|
|
128
|
+
);
|
|
129
|
+
expect(capsA).toEqual({ maxItems: 5 });
|
|
130
|
+
|
|
131
|
+
const capsB = await stack.http.queryOk<TestCaps>(
|
|
132
|
+
RESOLVED_CAPS_QN,
|
|
133
|
+
{},
|
|
134
|
+
tenantAdminOnlyFor(tenantB),
|
|
135
|
+
);
|
|
136
|
+
expect(capsB).toEqual({ maxItems: 1 });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.278.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "createTierResolver's resolveTier/resolveTierCaps take only a TenantDb — no separate tenantId argument (fw#2854).",
|
|
6
|
+
"detail": "`resolveTier(db)` and `resolveTierCaps(db)` (from `createTierResolver`) now read the tenant from `db.tenantId` instead of taking a second `tenantId` parameter, so a caller can no longer pass a `TenantDb` for one tenant alongside a different `tenantId` and resolve a foreign tenant's tier.",
|
|
7
|
+
"migration": "`resolveTier(ctx.db.raw|runner, tenantId)` → `resolveTier(ctx.db)` (same for `resolveTierCaps`). Outside a handler: `resolveTier(createTenantDb(runner, tenantId))`."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.241.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// only per-app variables were the TierName union and its caps/defaults, so
|
|
4
4
|
// both are now factory parameters.
|
|
5
5
|
|
|
6
|
-
import { buildEntityTable, type
|
|
7
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { buildEntityTable, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
7
|
import { tierAssignmentEntity } from "./entity";
|
|
9
8
|
|
|
10
9
|
export type TierResolverDeps<TTier extends string, TCaps> = {
|
|
@@ -18,14 +17,16 @@ export function createTierResolver<TTier extends string, TCaps>(
|
|
|
18
17
|
) {
|
|
19
18
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
20
19
|
|
|
21
|
-
async function resolveTier(db:
|
|
22
|
-
const row = await fetchOne<{ tier?: unknown }>(
|
|
20
|
+
async function resolveTier(db: TenantDb): Promise<TTier> {
|
|
21
|
+
const row = await db.fetchOne<{ tier?: unknown }>(tierAssignmentTable, {
|
|
22
|
+
tenantId: db.tenantId,
|
|
23
|
+
});
|
|
23
24
|
const tier = row?.tier;
|
|
24
25
|
return typeof tier === "string" && deps.isTierName(tier) ? tier : deps.defaultTier;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
async function resolveTierCaps(db:
|
|
28
|
-
return deps.capsForTier(await resolveTier(db
|
|
28
|
+
async function resolveTierCaps(db: TenantDb): Promise<TCaps> {
|
|
29
|
+
return deps.capsForTier(await resolveTier(db));
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
return { tierAssignmentTable, resolveTier, resolveTierCaps };
|
|
@@ -19,6 +19,7 @@ import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
|
|
|
19
19
|
import {
|
|
20
20
|
defineFeature,
|
|
21
21
|
EXT_USER_DATA,
|
|
22
|
+
SYSTEM_TENANT_ID,
|
|
22
23
|
type UserDataDeleteHook,
|
|
23
24
|
type UserDataExportHook,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -49,7 +50,7 @@ let stack: TestStack;
|
|
|
49
50
|
|
|
50
51
|
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
51
52
|
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
52
|
-
const TENANT_SYSTEM =
|
|
53
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
53
54
|
|
|
54
55
|
function uuid(suffix: number): string {
|
|
55
56
|
return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
|
|
@@ -77,20 +78,10 @@ const testNotesTable = defineUnmanagedTable({
|
|
|
77
78
|
// Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
|
|
78
79
|
// etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
|
|
79
80
|
const exportNotes: UserDataExportHook = async (ctx) => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
FROM test_notes
|
|
84
|
-
WHERE tenant_id = $1 AND author_id = $2
|
|
85
|
-
`,
|
|
86
|
-
[ctx.tenantId, ctx.userId],
|
|
81
|
+
const rows = await ctx.db.selectMany<{ id: string; title: string; body: string }>(
|
|
82
|
+
testNotesTable,
|
|
83
|
+
{ tenantId: ctx.tenantId, authorId: ctx.userId },
|
|
87
84
|
);
|
|
88
|
-
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
89
|
-
const rows = ((result as any).rows ?? result) as Array<{
|
|
90
|
-
id: string;
|
|
91
|
-
title: string;
|
|
92
|
-
body: string;
|
|
93
|
-
}>;
|
|
94
85
|
if (rows.length === 0) return null;
|
|
95
86
|
return {
|
|
96
87
|
entity: "note",
|