@cosmicdrift/kumiko-bundled-features 0.287.0 → 0.289.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 +10 -9
- package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +110 -3
- package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/subscription-tier-sync.ts +9 -4
- package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
- package/src/files-tenant-data/hooks.ts +101 -3
- package/src/managed-pages/handlers/branding.query.ts +1 -0
- package/src/managed-pages/handlers/by-slug.query.ts +1 -0
- package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
- package/src/seo/handlers/seo-config.query.ts +1 -0
- package/src/template-resolver/handlers/by-slug.query.ts +1 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
- package/src/tenant-handover/changes.json +8 -0
- package/src/tenant-handover/events.ts +26 -0
- package/src/tenant-handover/feature.ts +35 -0
- package/src/tenant-handover/grant.ts +43 -0
- package/src/tenant-handover/handlers/claim.write.ts +143 -0
- package/src/tenant-handover/index.ts +9 -0
- package/src/tenant-handover/move-entity-graph.ts +228 -0
- package/src/tenant-handover/transfer-graph.ts +44 -0
- package/src/tenant-lifecycle/stages.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/deletion-token.ts +9 -11
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
- package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
- package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.289.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"./tenant/web": "./src/tenant/web/index.ts",
|
|
83
83
|
"./tenant/seeding": "./src/tenant/seeding.ts",
|
|
84
84
|
"./tenant-lifecycle": "./src/tenant-lifecycle/index.ts",
|
|
85
|
+
"./tenant-handover": "./src/tenant-handover/index.ts",
|
|
85
86
|
"./tenant/testing": "./src/tenant/testing.ts",
|
|
86
87
|
"./user": "./src/user/index.ts",
|
|
87
88
|
"./user/seeding": "./src/user/seeding.ts",
|
|
@@ -132,12 +133,12 @@
|
|
|
132
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
133
134
|
},
|
|
134
135
|
"dependencies": {
|
|
135
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.289.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.289.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.289.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.289.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.289.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.289.0",
|
|
141
142
|
"@mollie/api-client": "^4.5.0",
|
|
142
143
|
"@node-rs/argon2": "^2.0.2",
|
|
143
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -166,8 +167,8 @@
|
|
|
166
167
|
],
|
|
167
168
|
"devDependencies": {
|
|
168
169
|
"@testing-library/user-event": "^14.6.1",
|
|
169
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
170
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.289.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.289.0",
|
|
171
172
|
"jsqr": "^1.4.0"
|
|
172
173
|
}
|
|
173
174
|
}
|
|
@@ -104,6 +104,9 @@ const TRIES_UNSAFE_RAW_WRITE_QN = "queryasmemberprobe:query:tries-unsafe-raw-wri
|
|
|
104
104
|
const TRIES_READ_WRITE_RESET_QN = "queryasmemberprobe:query:tries-read-write-reset";
|
|
105
105
|
const NESTED_DB_WRITE_QN = "queryasmemberprobe:query:nested-db-write";
|
|
106
106
|
const TRIES_SELECT_FOR_UPDATE_QN = "queryasmemberprobe:query:tries-select-for-update";
|
|
107
|
+
const TRIES_COMMIT_THEN_WRITE_QN = "queryasmemberprobe:query:tries-commit-then-write";
|
|
108
|
+
const TRIES_RAW_SELECT_QN = "queryasmemberprobe:query:tries-raw-select";
|
|
109
|
+
const NESTED_RAW_SELECT_QN = "queryasmemberprobe:query:nested-raw-select";
|
|
107
110
|
const TRIES_QUERY_AS_QN = "queryasmemberprobe:query:tries-query-as";
|
|
108
111
|
const READ_AS_MEMBER_QUERY_QN = "queryasmemberprobe:query:read-as-member-query";
|
|
109
112
|
const SYSTEM_SCOPE_READ_AS_MEMBER_QN = "queryasmembersystemscope:write:read-as-member";
|
|
@@ -240,6 +243,42 @@ const probeFeature = defineFeature("queryasmemberprobe", (r) => {
|
|
|
240
243
|
escapeHatch: { reason: "test: select-for-update probe" },
|
|
241
244
|
},
|
|
242
245
|
);
|
|
246
|
+
// Transaction control, not a write: COMMIT ends the read-only scope, the
|
|
247
|
+
// UPDATE afterwards runs in a fresh, writable implicit transaction.
|
|
248
|
+
r.queryHandler(
|
|
249
|
+
"tries-commit-then-write",
|
|
250
|
+
z.object({}),
|
|
251
|
+
async (_query, ctx) => {
|
|
252
|
+
const raw = asRawClient(ctx.db.unsafeRaw("test: commit-then-write probe"));
|
|
253
|
+
await raw.unsafe("COMMIT");
|
|
254
|
+
await raw.unsafe("UPDATE qam_notes SET body = 'tampered'");
|
|
255
|
+
return { ok: true };
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
access: { roles: ["Admin", "User"] },
|
|
259
|
+
escapeHatch: { reason: "test: commit-then-write probe" },
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
r.queryHandler(
|
|
263
|
+
"tries-raw-select",
|
|
264
|
+
z.object({}),
|
|
265
|
+
async (_query, ctx) => {
|
|
266
|
+
const rows = await asRawClient(ctx.db.unsafeRaw("test: raw select probe")).unsafe(
|
|
267
|
+
"SELECT 1 AS one",
|
|
268
|
+
);
|
|
269
|
+
return { ok: true, count: Array.isArray(rows) ? rows.length : 0 };
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
access: { roles: ["Admin", "User"] },
|
|
273
|
+
escapeHatch: { reason: "test: raw select probe" },
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
r.queryHandler(
|
|
277
|
+
"nested-raw-select",
|
|
278
|
+
z.object({}),
|
|
279
|
+
async (_query, ctx) => ctx.query(TRIES_RAW_SELECT_QN, {}),
|
|
280
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
281
|
+
);
|
|
243
282
|
r.queryHandler(
|
|
244
283
|
"tries-query-as",
|
|
245
284
|
z.object({}),
|
|
@@ -951,9 +990,10 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
|
|
|
951
990
|
await addMembership(userId, TENANT_A);
|
|
952
991
|
await createNote(userId, "note-1");
|
|
953
992
|
|
|
954
|
-
//
|
|
993
|
+
// Denied at ctx.db.unsafeRaw, before the SET TRANSACTION READ WRITE reaches Postgres.
|
|
955
994
|
const err = await readAsMemberErr(userId, TRIES_READ_WRITE_RESET_QN);
|
|
956
|
-
expect(err.code).toBe("
|
|
995
|
+
expect(err.code).toBe("access_denied");
|
|
996
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
957
997
|
expect(await readNoteBodies()).toEqual(["note-1"]);
|
|
958
998
|
});
|
|
959
999
|
|
|
@@ -964,7 +1004,8 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
|
|
|
964
1004
|
await createNote(userId, "note-1");
|
|
965
1005
|
|
|
966
1006
|
const err = await readAsMemberViaQueryErr(userId, TRIES_READ_WRITE_RESET_QN);
|
|
967
|
-
expect(err.code).toBe("
|
|
1007
|
+
expect(err.code).toBe("access_denied");
|
|
1008
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
968
1009
|
expect(await readNoteBodies()).toEqual(["note-1"]);
|
|
969
1010
|
});
|
|
970
1011
|
|
|
@@ -1047,4 +1088,70 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
|
|
|
1047
1088
|
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
1048
1089
|
expect(await readNoteBodies()).toEqual(["note-1"]);
|
|
1049
1090
|
});
|
|
1091
|
+
|
|
1092
|
+
test("j1) tries-commit-then-write via a write-caller (savepoint path) is denied, notes unchanged", async () => {
|
|
1093
|
+
await createTenant(TENANT_A);
|
|
1094
|
+
const userId = await createUser("committhenwrite-write@example.com", "pw-long-enough-36");
|
|
1095
|
+
await addMembership(userId, TENANT_A);
|
|
1096
|
+
await createNote(userId, "note-1");
|
|
1097
|
+
|
|
1098
|
+
const err = await readAsMemberErr(userId, TRIES_COMMIT_THEN_WRITE_QN);
|
|
1099
|
+
expect(err.code).toBe("access_denied");
|
|
1100
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
1101
|
+
expect(await readNoteBodies()).toEqual(["note-1"]);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test("j2) tries-commit-then-write via a query-caller (pool path) is denied, notes unchanged", async () => {
|
|
1105
|
+
await createTenant(TENANT_A);
|
|
1106
|
+
const userId = await createUser("committhenwrite-query@example.com", "pw-long-enough-37");
|
|
1107
|
+
await addMembership(userId, TENANT_A);
|
|
1108
|
+
await createNote(userId, "note-1");
|
|
1109
|
+
|
|
1110
|
+
const err = await readAsMemberViaQueryErr(userId, TRIES_COMMIT_THEN_WRITE_QN);
|
|
1111
|
+
expect(err.code).toBe("access_denied");
|
|
1112
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
1113
|
+
expect(await readNoteBodies()).toEqual(["note-1"]);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("k1) tries-raw-select via a write-caller is denied — no raw handle, not even for reads", async () => {
|
|
1117
|
+
await createTenant(TENANT_A);
|
|
1118
|
+
const userId = await createUser("rawselect-write@example.com", "pw-long-enough-38");
|
|
1119
|
+
await addMembership(userId, TENANT_A);
|
|
1120
|
+
|
|
1121
|
+
const err = await readAsMemberErr(userId, TRIES_RAW_SELECT_QN);
|
|
1122
|
+
expect(err.code).toBe("access_denied");
|
|
1123
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("k2) tries-raw-select via a query-caller (pool path) is denied", async () => {
|
|
1127
|
+
await createTenant(TENANT_A);
|
|
1128
|
+
const userId = await createUser("rawselect-query@example.com", "pw-long-enough-39");
|
|
1129
|
+
await addMembership(userId, TENANT_A);
|
|
1130
|
+
|
|
1131
|
+
const err = await readAsMemberViaQueryErr(userId, TRIES_RAW_SELECT_QN);
|
|
1132
|
+
expect(err.code).toBe("access_denied");
|
|
1133
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
test("l) nested-raw-select: a ctx.query hop inside a member read stays without a raw handle", async () => {
|
|
1137
|
+
await createTenant(TENANT_A);
|
|
1138
|
+
const userId = await createUser("nestedrawselect@example.com", "pw-long-enough-40");
|
|
1139
|
+
await addMembership(userId, TENANT_A);
|
|
1140
|
+
|
|
1141
|
+
const err = await readAsMemberErr(userId, NESTED_RAW_SELECT_QN);
|
|
1142
|
+
expect(err.code).toBe("access_denied");
|
|
1143
|
+
expect(errorReason(err.details)).toBe("member_resolution_read_only");
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
test("m) tries-raw-select via the normal dispatch path (no queryAsMember) still works", async () => {
|
|
1147
|
+
await createTenant(TENANT_A);
|
|
1148
|
+
|
|
1149
|
+
const result = await stack.http.queryOk<{ ok: boolean; count: number }>(
|
|
1150
|
+
TRIES_RAW_SELECT_QN,
|
|
1151
|
+
{},
|
|
1152
|
+
admin,
|
|
1153
|
+
);
|
|
1154
|
+
expect(result.ok).toBe(true);
|
|
1155
|
+
expect(result.count).toBe(1);
|
|
1156
|
+
});
|
|
1050
1157
|
});
|
|
@@ -14,6 +14,7 @@ export const selfRegistrationStatusQuery = defineQueryHandler({
|
|
|
14
14
|
name: "signup-registration-status",
|
|
15
15
|
schema: z.object({}),
|
|
16
16
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
17
|
+
rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
|
|
17
18
|
description:
|
|
18
19
|
"Reports whether self-registration is currently switched on, readable without signing in, so a signup page can hide its own form instead of collecting an address that would be silently ignored.",
|
|
19
20
|
handler: async (_query, ctx) => {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Integration test for createSubscriptionTierSync()'s onSyncError option
|
|
2
|
+
// (#3027). Forces a sync failure via a dispatchSystemWrite stub that fails
|
|
3
|
+
// only the tier-engine handlers (the primary billing-foundation writes run
|
|
4
|
+
// for real against the test DB) — proving that "log" (default) still
|
|
5
|
+
// answers the webhook successfully, while "fail-webhook" surfaces the sync
|
|
6
|
+
// error all the way into the HTTP response.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
10
|
+
import { buildEntityTable, type EntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
defineFeature,
|
|
13
|
+
type EntityDefinition,
|
|
14
|
+
type TenantId,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
createTestUser,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
testTenantId,
|
|
22
|
+
unsafeCreateEntityTable,
|
|
23
|
+
unsafePushTables,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
26
|
+
import { Hono } from "hono";
|
|
27
|
+
import {
|
|
28
|
+
createComplianceProfilesFeature,
|
|
29
|
+
tenantComplianceProfileEntity,
|
|
30
|
+
} from "../../compliance-profiles";
|
|
31
|
+
import { createConfigFeature } from "../../config";
|
|
32
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
33
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
34
|
+
import type { TierMap } from "../../tier-engine/compose-app";
|
|
35
|
+
import { TierEngineHandlers } from "../../tier-engine/constants";
|
|
36
|
+
import { tierAssignmentEntity } from "../../tier-engine/entity";
|
|
37
|
+
import { createTierEngineFeature } from "../../tier-engine/feature";
|
|
38
|
+
import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
|
|
39
|
+
import { billingFoundationFeature } from "../feature";
|
|
40
|
+
import {
|
|
41
|
+
createSubscriptionTierSync,
|
|
42
|
+
SUBSCRIPTION_WEBHOOK_PATH,
|
|
43
|
+
type SystemWriteResult,
|
|
44
|
+
} from "../subscription-tier-sync";
|
|
45
|
+
import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
|
|
46
|
+
|
|
47
|
+
type TestTier = "free" | "pro";
|
|
48
|
+
const isTierName = (v: string): v is TestTier => v === "free" || v === "pro";
|
|
49
|
+
const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
|
|
50
|
+
free: { features: [], caps: { maxItems: 1 } },
|
|
51
|
+
pro: { features: [], caps: { maxItems: 100 } },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const mockProviderFeature = defineFeature("test-mock-tier-sync-provider", (r) => {
|
|
55
|
+
r.requires("billing-foundation");
|
|
56
|
+
const plugin: SubscriptionProviderPlugin = {
|
|
57
|
+
verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
|
|
58
|
+
};
|
|
59
|
+
r.useExtension("subscriptionProvider", "mock", plugin);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Boundary-cast: SubscriptionTierSyncDeps deliberately erases the entity's
|
|
63
|
+
// concrete field types to EntityDefinition (generic factory), which TS
|
|
64
|
+
// can't structurally widen to on its own.
|
|
65
|
+
const tierAssignmentTable = buildEntityTable(
|
|
66
|
+
"tier-assignment",
|
|
67
|
+
tierAssignmentEntity,
|
|
68
|
+
) as unknown as EntityTable<EntityDefinition>;
|
|
69
|
+
|
|
70
|
+
let stack: TestStack;
|
|
71
|
+
let forceTierSyncFailure = false;
|
|
72
|
+
|
|
73
|
+
async function dispatchSystemWrite(args: {
|
|
74
|
+
readonly handlerQn: string;
|
|
75
|
+
readonly payload: unknown;
|
|
76
|
+
readonly tenantId: TenantId;
|
|
77
|
+
}): Promise<SystemWriteResult> {
|
|
78
|
+
if (
|
|
79
|
+
forceTierSyncFailure &&
|
|
80
|
+
(args.handlerQn === TierEngineHandlers.create || args.handlerQn === TierEngineHandlers.update)
|
|
81
|
+
) {
|
|
82
|
+
return {
|
|
83
|
+
isSuccess: false,
|
|
84
|
+
error: { code: "test_forced_failure", message: "forced tier-sync failure" },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const systemUser = createTestUser({ id: 1, tenantId: args.tenantId, roles: ["SystemAdmin"] });
|
|
88
|
+
const res = await stack.http.write(args.handlerQn, args.payload, systemUser);
|
|
89
|
+
const body = (await res.json()) as {
|
|
90
|
+
isSuccess: boolean;
|
|
91
|
+
data?: unknown;
|
|
92
|
+
error?: { readonly code?: string; readonly message?: string };
|
|
93
|
+
};
|
|
94
|
+
return body.isSuccess
|
|
95
|
+
? { isSuccess: true, data: body.data }
|
|
96
|
+
: { isSuccess: false, error: body.error };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let logApp: Hono;
|
|
100
|
+
let failApp: Hono;
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
stack = await setupTestStack({
|
|
104
|
+
features: [
|
|
105
|
+
createConfigFeature(),
|
|
106
|
+
createTenantFeature(),
|
|
107
|
+
createComplianceProfilesFeature(),
|
|
108
|
+
createTenantLifecycleFeature(),
|
|
109
|
+
billingFoundationFeature,
|
|
110
|
+
createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
|
|
111
|
+
mockProviderFeature,
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
await createEventsTable(stack.db);
|
|
115
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
116
|
+
await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
|
|
117
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
118
|
+
|
|
119
|
+
const sharedDeps = {
|
|
120
|
+
db: stack.db,
|
|
121
|
+
registry: stack.registry,
|
|
122
|
+
dispatchSystemWrite,
|
|
123
|
+
tierAssignmentTable,
|
|
124
|
+
isTierName,
|
|
125
|
+
defaultTier: "free" as const,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
logApp = new Hono();
|
|
129
|
+
createSubscriptionTierSync<TestTier>(sharedDeps).wireSubscriptionWebhookRoute(logApp);
|
|
130
|
+
|
|
131
|
+
failApp = new Hono();
|
|
132
|
+
createSubscriptionTierSync<TestTier>({
|
|
133
|
+
...sharedDeps,
|
|
134
|
+
onSyncError: "fail-webhook",
|
|
135
|
+
}).wireSubscriptionWebhookRoute(failApp);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterAll(async () => {
|
|
139
|
+
await stack.cleanup();
|
|
140
|
+
resetPiiSubjectKmsForTests();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const webhookPath = SUBSCRIPTION_WEBHOOK_PATH.replace(":providerName", "mock");
|
|
144
|
+
|
|
145
|
+
function buildEvent(tenantId: string, providerEventId: string): SubscriptionEvent {
|
|
146
|
+
return {
|
|
147
|
+
providerEventId,
|
|
148
|
+
providerName: "mock",
|
|
149
|
+
type: SubscriptionEventTypes.created,
|
|
150
|
+
tenantId,
|
|
151
|
+
providerCustomerId: `cus_${providerEventId}`,
|
|
152
|
+
providerSubscriptionId: `sub_${providerEventId}`,
|
|
153
|
+
status: SubscriptionStatuses.active,
|
|
154
|
+
tier: "pro",
|
|
155
|
+
currentPeriodEnd: "2026-06-01T00:00:00Z",
|
|
156
|
+
rawPayload: "{}",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe("createSubscriptionTierSync onSyncError", () => {
|
|
161
|
+
test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
|
|
162
|
+
forceTierSyncFailure = true;
|
|
163
|
+
const tenantId = testTenantId(9001);
|
|
164
|
+
const res = await logApp.request(webhookPath, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
body: JSON.stringify(buildEvent(tenantId, "evt_log_1")),
|
|
167
|
+
});
|
|
168
|
+
forceTierSyncFailure = false;
|
|
169
|
+
|
|
170
|
+
expect(res.status).toBe(200);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("'fail-webhook': a tier-sync failure also fails the webhook response", async () => {
|
|
174
|
+
forceTierSyncFailure = true;
|
|
175
|
+
const tenantId = testTenantId(9002);
|
|
176
|
+
const res = await failApp.request(webhookPath, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: JSON.stringify(buildEvent(tenantId, "evt_fail_1")),
|
|
179
|
+
});
|
|
180
|
+
forceTierSyncFailure = false;
|
|
181
|
+
|
|
182
|
+
expect(res.status).toBe(500);
|
|
183
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
184
|
+
expect(body.error.code).toBe("subscription_webhook_processing_failed");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.288.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "add onSyncError option to createSubscriptionTierSync",
|
|
6
|
+
"detail": "`SubscriptionTierSyncDeps<TTier>` gets an optional `onSyncError?: \"log\" | \"fail-webhook\"`\n(default `\"log\"`, matching prior behavior exactly). `\"fail-webhook\"` makes a tier-sync\nfailure also fail the webhook response, so an idempotent-retry caller (whose retry\nno-ops the already-committed primary write) gets another attempt at the sync step."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.286.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -35,6 +35,12 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
|
|
|
35
35
|
readonly tierAssignmentTable: EntityTable<EntityDefinition>;
|
|
36
36
|
readonly isTierName: (value: string) => value is TTier;
|
|
37
37
|
readonly defaultTier: TTier;
|
|
38
|
+
// "log" (default): sync failure only warns, the webhook still reports
|
|
39
|
+
// success — right when the caller has no idempotent retry to lean on.
|
|
40
|
+
// "fail-webhook": sync failure fails the webhook response too, so an
|
|
41
|
+
// idempotent caller (e.g. Stripe, whose retry re-runs the already-committed
|
|
42
|
+
// primary write as a no-op) gets a second attempt at the sync step itself.
|
|
43
|
+
readonly onSyncError?: "log" | "fail-webhook";
|
|
38
44
|
};
|
|
39
45
|
|
|
40
46
|
export function effectiveTierFromSubscription<TTier extends string>(
|
|
@@ -116,16 +122,15 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
116
122
|
tenantId: targetTenantId,
|
|
117
123
|
});
|
|
118
124
|
if (!result.isSuccess) return result;
|
|
119
|
-
// The primary write already committed — a webhook caller (Stripe/
|
|
120
|
-
// PayPal) that sees isSuccess:false here retries the whole event,
|
|
121
|
-
// re-running an already-succeeded side effect. Log the tier-sync
|
|
122
|
-
// failure instead of masking the primary write's success.
|
|
123
125
|
const syncError = await syncTierFromSubscription(targetTenantId);
|
|
124
126
|
if (syncError) {
|
|
125
127
|
// biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
|
|
126
128
|
console.warn(
|
|
127
129
|
`[subscription-tier-sync] tier sync failed for tenant ${targetTenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
|
|
128
130
|
);
|
|
131
|
+
if (deps.onSyncError === "fail-webhook") {
|
|
132
|
+
return { isSuccess: false, error: syncError };
|
|
133
|
+
}
|
|
129
134
|
}
|
|
130
135
|
return result;
|
|
131
136
|
},
|
|
@@ -25,6 +25,7 @@ export const subProcessorsQuery = defineQueryHandler({
|
|
|
25
25
|
name: "sub-processors",
|
|
26
26
|
schema: z.object({}),
|
|
27
27
|
access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
|
|
28
|
+
rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
|
|
28
29
|
description:
|
|
29
30
|
"Returns the publicly readable Kumiko sub-processor list split into active and planned entries, for the GDPR Art. 28(2) disclosure a privacy policy or data-processing agreement links to.",
|
|
30
31
|
handler: async (): Promise<SubProcessorListResponse> => {
|
|
@@ -12,7 +12,11 @@ import { randomBytes } from "node:crypto";
|
|
|
12
12
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
14
14
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
createSystemUser,
|
|
17
|
+
type EntityId,
|
|
18
|
+
type TenantId,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
20
|
import {
|
|
17
21
|
append,
|
|
18
22
|
createEventsTable,
|
|
@@ -133,6 +137,12 @@ async function seedFileRef(tenantId: TenantId, storageKey: string) {
|
|
|
133
137
|
return { id: result.data.id };
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
// Stands in for a tenant-handover claim (kumiko-framework#3035): the row's
|
|
141
|
+
// tenantId moves, the storageKey — and the bytes behind it — do not.
|
|
142
|
+
async function handOverFileRef(fileRefId: EntityId, toTenantId: TenantId): Promise<void> {
|
|
143
|
+
await updateRows(db, fileRefsTable, { tenantId: toTenantId }, { id: String(fileRefId) });
|
|
144
|
+
}
|
|
145
|
+
|
|
136
146
|
// Sidesteps the `request-destruction` write handler (needs user/auth/sessions
|
|
137
147
|
// features wired) by seeding the same "destroying" state it would produce —
|
|
138
148
|
// same pattern as tenant-lifecycle's own poison-pill destroy tests.
|
|
@@ -279,4 +289,81 @@ describe("files-tenant-data :: tenant destroy", () => {
|
|
|
279
289
|
// forget hook's resolution-failure stance.
|
|
280
290
|
expect(await provider.exists(originalKeyA)).toBe(true);
|
|
281
291
|
});
|
|
292
|
+
|
|
293
|
+
// kumiko-framework#3035 — tenant-handover leaves a fileRef row's tenantId
|
|
294
|
+
// pointing at a NEW tenant while its storageKey (and the bytes behind it)
|
|
295
|
+
// stay under the tenant that originally uploaded it.
|
|
296
|
+
describe("after a tenant-handover", () => {
|
|
297
|
+
test("the destination tenant's destroy deletes the handed-over binary even though it lives outside its own prefix", async () => {
|
|
298
|
+
await seedTenant(tenantA);
|
|
299
|
+
await seedTenant(tenantB);
|
|
300
|
+
|
|
301
|
+
const handedOverKey = buildStorageKey(
|
|
302
|
+
tenantA.tenantId,
|
|
303
|
+
"fileRef",
|
|
304
|
+
1,
|
|
305
|
+
"attachment",
|
|
306
|
+
"photo.jpg",
|
|
307
|
+
"u1",
|
|
308
|
+
);
|
|
309
|
+
const { id: fileRefId } = await seedFileRef(tenantA.tenantId, handedOverKey);
|
|
310
|
+
await provider.write(handedOverKey, new Uint8Array([1]));
|
|
311
|
+
|
|
312
|
+
await handOverFileRef(fileRefId, tenantB.tenantId);
|
|
313
|
+
|
|
314
|
+
await seedDestroyingTenant(tenantB.tenantId);
|
|
315
|
+
const finalStatus = await driveDestructionToCompletion(
|
|
316
|
+
tenantB.tenantId,
|
|
317
|
+
async () => provider,
|
|
318
|
+
);
|
|
319
|
+
expect(finalStatus).toBe("destroyed");
|
|
320
|
+
|
|
321
|
+
const rowsB = await selectMany(db, fileRefsTable, { tenantId: tenantB.tenantId });
|
|
322
|
+
expect(rowsB).toHaveLength(0);
|
|
323
|
+
expect(await provider.exists(handedOverKey)).toBe(false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("the SOURCE tenant's destroy does not delete a file — or its derivative — a foreign tenant claimed, but still sweeps a true orphan", async () => {
|
|
327
|
+
await seedTenant(tenantA);
|
|
328
|
+
await seedTenant(tenantB);
|
|
329
|
+
|
|
330
|
+
const originalKey = buildStorageKey(
|
|
331
|
+
tenantA.tenantId,
|
|
332
|
+
"fileRef",
|
|
333
|
+
1,
|
|
334
|
+
"attachment",
|
|
335
|
+
"photo.jpg",
|
|
336
|
+
"u1",
|
|
337
|
+
);
|
|
338
|
+
const derivativeKey = `${originalKey.replace(/\.jpg$/, "")}.medium.jpg`;
|
|
339
|
+
const orphanKey = `${tenantA.tenantId}/some-other-file.bin`;
|
|
340
|
+
const { id: fileRefId } = await seedFileRef(tenantA.tenantId, originalKey);
|
|
341
|
+
await provider.write(originalKey, new Uint8Array([1]));
|
|
342
|
+
await provider.write(derivativeKey, new Uint8Array([2]));
|
|
343
|
+
await provider.write(orphanKey, new Uint8Array([3]));
|
|
344
|
+
|
|
345
|
+
await handOverFileRef(fileRefId, tenantB.tenantId);
|
|
346
|
+
|
|
347
|
+
await seedDestroyingTenant(tenantA.tenantId);
|
|
348
|
+
const finalStatus = await driveDestructionToCompletion(
|
|
349
|
+
tenantA.tenantId,
|
|
350
|
+
async () => provider,
|
|
351
|
+
);
|
|
352
|
+
expect(finalStatus).toBe("destroyed");
|
|
353
|
+
|
|
354
|
+
// A's own app-data stage never even sees this row — by the time it ran,
|
|
355
|
+
// the handover had already moved it to B.
|
|
356
|
+
const rowsB = await selectMany(db, fileRefsTable, { tenantId: tenantB.tenantId });
|
|
357
|
+
expect(rowsB).toHaveLength(1);
|
|
358
|
+
|
|
359
|
+
// The silent-data-loss case the whole handover design exists to
|
|
360
|
+
// prevent: A's storage-prefix sweep must not delete bytes a foreign
|
|
361
|
+
// tenant's surviving row still references — original AND derivative.
|
|
362
|
+
expect(await provider.exists(originalKey)).toBe(true);
|
|
363
|
+
expect(await provider.exists(derivativeKey)).toBe(true);
|
|
364
|
+
// A true orphan (no fileRef row anywhere) is still swept — the
|
|
365
|
+
// exception above is narrow, not a general retreat from the sweep.
|
|
366
|
+
expect(await provider.exists(orphanKey)).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
282
369
|
});
|