@cosmicdrift/kumiko-bundled-features 0.220.1 → 0.221.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/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +6 -1
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/feature.ts +7 -4
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +2 -2
- package/src/tenant/handlers/update-member-roles.write.ts +1 -1
- package/src/tenant/i18n.ts +2 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +18 -5
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
|
@@ -203,12 +203,10 @@ describe("createCustomFieldsFeature access-options", () => {
|
|
|
203
203
|
expect(writeAccess(feature, "delete-system-field")).toEqual(["SystemAdmin"]);
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
-
test("fieldDefinitionWriteRoles feeds into
|
|
206
|
+
test("fieldDefinitionWriteRoles feeds into list roles without keeping default TenantAdmin", () => {
|
|
207
207
|
const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin", "Editor"] });
|
|
208
208
|
const roles = listAccess(feature);
|
|
209
|
-
expect(roles).
|
|
210
|
-
expect(roles).toContain("Editor");
|
|
211
|
-
expect(roles).toContain("TenantAdmin");
|
|
209
|
+
expect(roles).toEqual(["Admin", "Editor"]);
|
|
212
210
|
});
|
|
213
211
|
|
|
214
212
|
test("fieldDefinitionWriteRoles and valueWriteRoles are independent — setting one leaves the other's default", () => {
|
|
@@ -219,23 +217,23 @@ describe("createCustomFieldsFeature access-options", () => {
|
|
|
219
217
|
});
|
|
220
218
|
|
|
221
219
|
describe("resolveFieldDefinitionListRoles", () => {
|
|
222
|
-
test("
|
|
220
|
+
test("nothing set → default only", () => {
|
|
223
221
|
expect(resolveFieldDefinitionListRoles({})).toEqual(["TenantAdmin"]);
|
|
224
222
|
});
|
|
225
223
|
|
|
226
|
-
test("valueWriteRoles
|
|
224
|
+
test("valueWriteRoles set, list unset → union with default, deduped", () => {
|
|
227
225
|
expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["Admin", "Editor"] })).toEqual([
|
|
228
226
|
"Admin",
|
|
229
227
|
"Editor",
|
|
230
228
|
"TenantAdmin",
|
|
231
229
|
]);
|
|
232
|
-
// TenantAdmin
|
|
230
|
+
// TenantAdmin already in valueWriteRoles → no duplicate.
|
|
233
231
|
expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["TenantAdmin", "Editor"] })).toEqual(
|
|
234
232
|
["TenantAdmin", "Editor"],
|
|
235
233
|
);
|
|
236
234
|
});
|
|
237
235
|
|
|
238
|
-
test("
|
|
236
|
+
test("explicit list roles always win (even over valueWriteRoles)", () => {
|
|
239
237
|
expect(
|
|
240
238
|
resolveFieldDefinitionListRoles({
|
|
241
239
|
valueWriteRoles: ["Admin"],
|
|
@@ -244,18 +242,18 @@ describe("resolveFieldDefinitionListRoles", () => {
|
|
|
244
242
|
).toEqual(["Viewer"]);
|
|
245
243
|
});
|
|
246
244
|
|
|
247
|
-
test("fieldDefinitionWriteRoles
|
|
245
|
+
test("fieldDefinitionWriteRoles set, list unset → write roles only (no default TenantAdmin)", () => {
|
|
248
246
|
expect(
|
|
249
247
|
resolveFieldDefinitionListRoles({ fieldDefinitionWriteRoles: ["Admin", "Editor"] }),
|
|
250
|
-
).toEqual(["Admin", "Editor"
|
|
248
|
+
).toEqual(["Admin", "Editor"]);
|
|
251
249
|
});
|
|
252
250
|
|
|
253
|
-
test("valueWriteRoles
|
|
251
|
+
test("valueWriteRoles AND fieldDefinitionWriteRoles → union of both, no default", () => {
|
|
254
252
|
expect(
|
|
255
253
|
resolveFieldDefinitionListRoles({
|
|
256
254
|
valueWriteRoles: ["Member"],
|
|
257
255
|
fieldDefinitionWriteRoles: ["Admin"],
|
|
258
256
|
}),
|
|
259
|
-
).toEqual(["Member", "Admin"
|
|
257
|
+
).toEqual(["Member", "Admin"]);
|
|
260
258
|
});
|
|
261
259
|
});
|
|
@@ -23,9 +23,10 @@ export const CustomFieldsQueries = {
|
|
|
23
23
|
fieldDefinitionList: "custom-fields:query:field-definition:list",
|
|
24
24
|
} as const;
|
|
25
25
|
|
|
26
|
-
// Name
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// Name the web component registers under in ExtensionSectionsProvider —
|
|
27
|
+
// apps reference it in the screen schema via:
|
|
28
|
+
// { kind: "extension", title, contributesToFormSubmit: true,
|
|
29
|
+
// component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } } }
|
|
29
30
|
export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
|
|
30
31
|
|
|
31
32
|
// Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
|
|
@@ -206,7 +206,7 @@ export type CustomFieldsFeatureOptions = {
|
|
|
206
206
|
* before #2296. Apps with their own role vocabulary (e.g. ["Admin","Editor"])
|
|
207
207
|
* MUST set this, else field-definition management is access_denied for every
|
|
208
208
|
* app-user without a "TenantAdmin" role. Only covers the tenant-scope triad —
|
|
209
|
-
* define-/delete-system-field stay hard-wired to ["
|
|
209
|
+
* define-/delete-system-field stay hard-wired to ["SystemAdmin"], since
|
|
210
210
|
* system-scope is a separate trust boundary that must not become reachable
|
|
211
211
|
* through a tenant role vocabulary. Also feeds into
|
|
212
212
|
* resolveFieldDefinitionListRoles (below) so a definer without an explicit
|
|
@@ -225,13 +225,12 @@ export function resolveFieldDefinitionListRoles(
|
|
|
225
225
|
if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
|
|
226
226
|
return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
|
|
227
227
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
];
|
|
228
|
+
// Write-roles override replaces the default role vocab — don't keep unioning
|
|
229
|
+
// TenantAdmin onto list when the app opted into its own write roles.
|
|
230
|
+
if (opts.fieldDefinitionWriteRoles !== undefined) {
|
|
231
|
+
return [...new Set([...(opts.valueWriteRoles ?? []), ...opts.fieldDefinitionWriteRoles])];
|
|
232
|
+
}
|
|
233
|
+
return [...new Set([...(opts.valueWriteRoles ?? []), ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
|
|
235
234
|
}
|
|
236
235
|
|
|
237
236
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
@@ -17,6 +17,7 @@ import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
|
17
17
|
|
|
18
18
|
type LogRow = {
|
|
19
19
|
readonly id: string;
|
|
20
|
+
readonly tenantId?: string;
|
|
20
21
|
readonly type: string;
|
|
21
22
|
readonly channel: string;
|
|
22
23
|
readonly recipient: string | null;
|
|
@@ -25,7 +26,9 @@ type LogRow = {
|
|
|
25
26
|
|
|
26
27
|
let stack: TestStack;
|
|
27
28
|
const tenantId = testTenantId(701);
|
|
29
|
+
const otherTenantId = testTenantId(702);
|
|
28
30
|
const admin = createTestUser({ id: 701, roles: ["TenantAdmin"], tenantId });
|
|
31
|
+
const systemAdmin = createTestUser({ id: 709, roles: ["SystemAdmin"], tenantId });
|
|
29
32
|
|
|
30
33
|
beforeAll(async () => {
|
|
31
34
|
stack = await setupTestStack({ features: [createDeliveryFeature()] });
|
|
@@ -55,6 +58,14 @@ beforeAll(async () => {
|
|
|
55
58
|
recipientAddress: null,
|
|
56
59
|
status: "queued",
|
|
57
60
|
},
|
|
61
|
+
{
|
|
62
|
+
id: crypto.randomUUID(),
|
|
63
|
+
tenantId: otherTenantId,
|
|
64
|
+
notificationType: "logtest:other",
|
|
65
|
+
channel: "email",
|
|
66
|
+
recipientAddress: "other@example.com",
|
|
67
|
+
status: "sent",
|
|
68
|
+
},
|
|
58
69
|
]);
|
|
59
70
|
});
|
|
60
71
|
|
|
@@ -115,6 +126,23 @@ describe("delivery:query:log — sort", () => {
|
|
|
115
126
|
);
|
|
116
127
|
expect(withInvalidSort.rows.map((r) => r.id)).toEqual(withNoSort.rows.map((r) => r.id));
|
|
117
128
|
});
|
|
129
|
+
|
|
130
|
+
test("sort=tenantId is whitelisted and reverses for SystemAdmin cross-tenant", async () => {
|
|
131
|
+
const asc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
|
|
132
|
+
DeliveryQueries.log,
|
|
133
|
+
{ sort: "tenantId", sortDirection: "asc" },
|
|
134
|
+
systemAdmin,
|
|
135
|
+
);
|
|
136
|
+
const desc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
|
|
137
|
+
DeliveryQueries.log,
|
|
138
|
+
{ sort: "tenantId", sortDirection: "desc" },
|
|
139
|
+
systemAdmin,
|
|
140
|
+
);
|
|
141
|
+
const ascTenants = [...new Set(asc.rows.map((r) => r.tenantId))];
|
|
142
|
+
const descTenants = [...new Set(desc.rows.map((r) => r.tenantId))];
|
|
143
|
+
expect(new Set(ascTenants)).toEqual(new Set([tenantId, otherTenantId]));
|
|
144
|
+
expect(descTenants).toEqual([...ascTenants].reverse());
|
|
145
|
+
});
|
|
118
146
|
});
|
|
119
147
|
|
|
120
148
|
describe("delivery:query:log — field mapping", () => {
|
|
@@ -169,7 +169,7 @@ describe("delivery log SystemAdmin cross-tenant scope", () => {
|
|
|
169
169
|
status: string;
|
|
170
170
|
error: string | null;
|
|
171
171
|
}[];
|
|
172
|
-
}>(DeliveryQueries.log, {}, systemAdmin);
|
|
172
|
+
}>(DeliveryQueries.log, { limit: 100 }, systemAdmin);
|
|
173
173
|
|
|
174
174
|
const byId = new Map(res.rows.map((r) => [r.id, r]));
|
|
175
175
|
expect(byId.get(systemConfirmationId)?.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
@@ -189,6 +189,97 @@ describe("delivery log SystemAdmin cross-tenant scope", () => {
|
|
|
189
189
|
);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
test("SystemAdmin cursor-paginates the unfiltered cross-tenant result set, seeing every row exactly once", async () => {
|
|
193
|
+
const pagingTenant = testTenantId(53);
|
|
194
|
+
const seededRows = Array.from({ length: 7 }, (_, i) => ({
|
|
195
|
+
id: crypto.randomUUID(),
|
|
196
|
+
tenantId: i % 2 === 0 ? SYSTEM_TENANT_ID : pagingTenant,
|
|
197
|
+
// unique per row so we can sort on it — the default sort (createdAt)
|
|
198
|
+
// ties across rows inserted in the same insertMany statement, and the
|
|
199
|
+
// cursor WHERE only excludes strictly-equal sort values (see
|
|
200
|
+
// log.query.ts), so equal timestamps would silently drop rows.
|
|
201
|
+
notificationType: `paging-test:${i}`,
|
|
202
|
+
channel: "email",
|
|
203
|
+
recipientAddress: null,
|
|
204
|
+
status: "sent",
|
|
205
|
+
}));
|
|
206
|
+
await insertMany(stack.db, deliveryAttemptsTable, seededRows);
|
|
207
|
+
|
|
208
|
+
const systemAdmin = createTestUser({
|
|
209
|
+
id: 52,
|
|
210
|
+
roles: ["SystemAdmin"],
|
|
211
|
+
tenantId: pagingTenant,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const seenCounts = new Map<string, number>();
|
|
215
|
+
let cursor: string | undefined;
|
|
216
|
+
let pageCount = 0;
|
|
217
|
+
do {
|
|
218
|
+
const page = await stack.http.queryOk<{
|
|
219
|
+
rows: readonly { id: string }[];
|
|
220
|
+
nextCursor: string | null;
|
|
221
|
+
}>(
|
|
222
|
+
DeliveryQueries.log,
|
|
223
|
+
{ limit: 3, sort: "type", sortDirection: "asc", cursor },
|
|
224
|
+
systemAdmin,
|
|
225
|
+
);
|
|
226
|
+
for (const row of page.rows) {
|
|
227
|
+
seenCounts.set(row.id, (seenCounts.get(row.id) ?? 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
cursor = page.nextCursor ?? undefined;
|
|
230
|
+
pageCount += 1;
|
|
231
|
+
expect(pageCount).toBeLessThan(50);
|
|
232
|
+
} while (cursor);
|
|
233
|
+
|
|
234
|
+
expect(pageCount).toBeGreaterThan(1);
|
|
235
|
+
for (const row of seededRows) {
|
|
236
|
+
expect(seenCounts.get(row.id)).toBe(1);
|
|
237
|
+
}
|
|
238
|
+
expect([...seenCounts.values()].every((count) => count === 1)).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("SystemAdmin with no demo-tenant session (session tenantId is SYSTEM_TENANT_ID itself) still sees cross-tenant rows", async () => {
|
|
242
|
+
const otherTenant = testTenantId(54);
|
|
243
|
+
const systemScopedId = crypto.randomUUID();
|
|
244
|
+
const otherTenantRowId = crypto.randomUUID();
|
|
245
|
+
|
|
246
|
+
await insertMany(stack.db, deliveryAttemptsTable, [
|
|
247
|
+
{
|
|
248
|
+
id: systemScopedId,
|
|
249
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
250
|
+
notificationType: "no-session-tenant-test:system",
|
|
251
|
+
channel: "email",
|
|
252
|
+
recipientAddress: null,
|
|
253
|
+
status: "sent",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: otherTenantRowId,
|
|
257
|
+
tenantId: otherTenant,
|
|
258
|
+
notificationType: "no-session-tenant-test:other",
|
|
259
|
+
channel: "email",
|
|
260
|
+
recipientAddress: null,
|
|
261
|
+
status: "sent",
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
// Mirrors a platform-level caller (e.g. an API key) that has no demo
|
|
266
|
+
// tenant on its session at all, as opposed to the other SystemAdmin
|
|
267
|
+
// tests above which browse from a demo tenant panel.
|
|
268
|
+
const systemAdmin = createTestUser({
|
|
269
|
+
id: 53,
|
|
270
|
+
roles: ["SystemAdmin"],
|
|
271
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const res = await stack.http.queryOk<{
|
|
275
|
+
rows: readonly { id: string; tenantId: string }[];
|
|
276
|
+
}>(DeliveryQueries.log, { limit: 100 }, systemAdmin);
|
|
277
|
+
|
|
278
|
+
const byId = new Map(res.rows.map((r) => [r.id, r]));
|
|
279
|
+
expect(byId.get(systemScopedId)?.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
280
|
+
expect(byId.get(otherTenantRowId)?.tenantId).toBe(otherTenant);
|
|
281
|
+
});
|
|
282
|
+
|
|
192
283
|
test("TenantAdmin cannot read SYSTEM_TENANT_ID or foreign-tenant delivery attempts", async () => {
|
|
193
284
|
const demoTenant = testTenantId(61);
|
|
194
285
|
const otherTenant = testTenantId(62);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
|
|
@@ -15,7 +15,8 @@ export async function selectNotificationPreferences(
|
|
|
15
15
|
notificationType: string,
|
|
16
16
|
channelName: string,
|
|
17
17
|
): Promise<readonly NotificationPreferenceRow[]> {
|
|
18
|
-
return
|
|
18
|
+
return unsafeReadRetrying<NotificationPreferenceRow>(
|
|
19
|
+
db,
|
|
19
20
|
`SELECT notification_type AS "notificationType", channel, enabled
|
|
20
21
|
FROM read_notification_preferences
|
|
21
22
|
WHERE tenant_id = $1
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// fetchOne(), throwing a Postgres 22P02 (malformed uuid literal) that
|
|
9
9
|
// poisons the pooled Bun.SQL connection, an unauth DoS primitive.
|
|
10
10
|
|
|
11
|
-
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
12
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
13
|
import {
|
|
14
14
|
createFilesFeature,
|
|
@@ -21,8 +21,10 @@ import { createFileDerivativesFeature } from "../feature";
|
|
|
21
21
|
import { PUBLIC_VARIANT_QN } from "../handlers/public-variant.query";
|
|
22
22
|
|
|
23
23
|
describe("file-derivatives :: publicVariant query fileRefId validation", () => {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
let stack: TestStack;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
stack = await setupTestStack({
|
|
26
28
|
features: [
|
|
27
29
|
createConfigFeature(),
|
|
28
30
|
fileFoundationFeature,
|
|
@@ -32,44 +34,31 @@ describe("file-derivatives :: publicVariant query fileRefId validation", () => {
|
|
|
32
34
|
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
33
35
|
files: { storageProvider: createInMemoryFileProvider() },
|
|
34
36
|
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await stack.cleanup();
|
|
41
|
+
});
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
test("non-UUID fileRefId via /api/query → 400 validation_error, never reaches the DB", async () => {
|
|
44
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
45
|
+
type: PUBLIC_VARIANT_QN,
|
|
46
|
+
payload: { fileRefId: "not-a-uuid; DROP TABLE file_refs;--", variant: "thumb" },
|
|
47
|
+
});
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} finally {
|
|
46
|
-
await stack.cleanup();
|
|
47
|
-
}
|
|
49
|
+
expect(res.status).toBe(400);
|
|
50
|
+
const body = (await res.json()) as { error?: { code?: string } };
|
|
51
|
+
expect(body.error?.code).toBe("validation_error");
|
|
48
52
|
});
|
|
49
53
|
|
|
50
54
|
test("well-formed but unknown UUID fileRefId via /api/query → 200 with null (schema passes, DB lookup just finds nothing)", async () => {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
fileFoundationFeature,
|
|
55
|
-
createFilesFeature(),
|
|
56
|
-
createFileDerivativesFeature({ resolveApexTenant: () => SYSTEM_TENANT_ID }),
|
|
57
|
-
],
|
|
58
|
-
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
59
|
-
files: { storageProvider: createInMemoryFileProvider() },
|
|
55
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
56
|
+
type: PUBLIC_VARIANT_QN,
|
|
57
|
+
payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
|
|
60
58
|
});
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(res.status).toBe(200);
|
|
69
|
-
const body = (await res.json()) as { data: unknown };
|
|
70
|
-
expect(body.data).toBeNull();
|
|
71
|
-
} finally {
|
|
72
|
-
await stack.cleanup();
|
|
73
|
-
}
|
|
60
|
+
expect(res.status).toBe(200);
|
|
61
|
+
const body = (await res.json()) as { data: unknown };
|
|
62
|
+
expect(body.data).toBeNull();
|
|
74
63
|
});
|
|
75
64
|
});
|
|
@@ -279,8 +279,8 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
|
|
|
279
279
|
test("a preset name the field doesn't declare in its variants → 404", async () => {
|
|
280
280
|
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
281
281
|
|
|
282
|
-
// widgetEntity's "img" field declares
|
|
283
|
-
// "hero" aren't in its variants map.
|
|
282
|
+
// widgetEntity's "img" field declares thumb/full/plan/heroWide/raw —
|
|
283
|
+
// "card" and "hero" aren't in its variants map.
|
|
284
284
|
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/card`);
|
|
285
285
|
|
|
286
286
|
expect(res.status).toBe(404);
|
|
@@ -33,6 +33,10 @@ const FEATURE_NAME = "file-derivatives";
|
|
|
33
33
|
// a name list here "for safety" — see #1985.
|
|
34
34
|
const VARIANT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
35
35
|
|
|
36
|
+
/** Host → tenantId for the anonymous media route.
|
|
37
|
+
* MUST be cheap/synchronous (in-memory map or cached lookup): it runs
|
|
38
|
+
* *before* the query-handler rate limit, so a DB round-trip here is an
|
|
39
|
+
* unthrottled DoS surface for anonymous callers rotating Host headers (#1977). */
|
|
36
40
|
export type PublicVariantResolveApexTenant = (
|
|
37
41
|
host: string,
|
|
38
42
|
) => Promise<TenantId | null> | TenantId | null;
|
|
@@ -42,7 +46,8 @@ export type FileDerivativesOptions = {
|
|
|
42
46
|
* Without this option, neither the httpRoute NOR the `publicVariant`
|
|
43
47
|
* query is registered — it is unreachable via the httpRoute path and via
|
|
44
48
|
* the generic `/api` query dispatch. Existing consumers that only need
|
|
45
|
-
* `ctx.derivatives` are unaffected.
|
|
49
|
+
* `ctx.derivatives` are unaffected. See {@link PublicVariantResolveApexTenant}
|
|
50
|
+
* for the in-memory/sync contract. */
|
|
46
51
|
readonly resolveApexTenant?: PublicVariantResolveApexTenant;
|
|
47
52
|
/** Base path of the public variant route. Default "/media". */
|
|
48
53
|
readonly basePath?: string;
|
|
@@ -52,9 +52,8 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
52
52
|
r.requires("file-foundation");
|
|
53
53
|
|
|
54
54
|
const secretAccessKey = r.secret("s3.secretAccessKey", {
|
|
55
|
-
label: {
|
|
55
|
+
label: { en: "S3 Secret Access Key" },
|
|
56
56
|
hint: {
|
|
57
|
-
de: "Privater Teil des S3-Schlüsselpaares. Bei Hetzner Object Storage 'Secret Key', bei AWS S3 'Secret Access Key'.",
|
|
58
57
|
en: "Private half of the S3 key pair. Hetzner calls it 'Secret Key', AWS calls it 'Secret Access Key'.",
|
|
59
58
|
},
|
|
60
59
|
redact: (plaintext) => {
|
|
@@ -165,15 +165,14 @@ async function dispatchCleanup(): Promise<void> {
|
|
|
165
165
|
|
|
166
166
|
describe("form-draft cleanup job", () => {
|
|
167
167
|
// FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY (cleanup.job.ts) is a hardcoded
|
|
168
|
-
// literal that must match
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
expect(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY).toBe(
|
|
175
|
-
qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab("retentionDays")),
|
|
168
|
+
// literal that must match a key actually registered by formDraftFeature.
|
|
169
|
+
// Assert against the feature's configKeys (not a hand-rebuilt qn string),
|
|
170
|
+
// so renaming the key in feature.ts without updating the constant fails.
|
|
171
|
+
test("FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY is among formDraftFeature registered config keys", () => {
|
|
172
|
+
const registered = Object.keys(formDraftFeature.configKeys ?? {}).map((key) =>
|
|
173
|
+
qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab(key)),
|
|
176
174
|
);
|
|
175
|
+
expect(registered).toContain(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY);
|
|
177
176
|
});
|
|
178
177
|
|
|
179
178
|
test("deletes a draft older than the default retention window, leaves a fresh one alone", async () => {
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// Chunked DELETE (default 1000/batch) keeps lock durations bounded, mirror
|
|
7
7
|
// of sessions/handlers/cleanup.job.ts.
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createEventStoreExecutor,
|
|
11
|
+
createTenantDb,
|
|
12
|
+
type DbConnection,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
10
14
|
import {
|
|
11
15
|
type AppContext,
|
|
12
16
|
access,
|
|
@@ -19,11 +23,16 @@ import {
|
|
|
19
23
|
type TenantId,
|
|
20
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
25
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
26
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
22
27
|
import { type StaleDraftRow, selectStaleDraftsBatch } from "../db/queries/cleanup";
|
|
23
|
-
import {
|
|
28
|
+
import { filterOwnedFileRefs } from "../db/queries/owned-file-refs";
|
|
24
29
|
import { formDraftExecutor } from "../executor";
|
|
25
30
|
import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
|
|
26
31
|
|
|
32
|
+
const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
33
|
+
entityName: "fileRef",
|
|
34
|
+
});
|
|
35
|
+
|
|
27
36
|
export const FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY = "form-draft:config:retention-days";
|
|
28
37
|
export const FORM_DRAFT_DEFAULT_RETENTION_DAYS = 30;
|
|
29
38
|
const DEFAULT_BATCH_SIZE = 1000;
|
|
@@ -48,27 +57,15 @@ async function releaseRowFileRefs(
|
|
|
48
57
|
// skip: no FileRefs in this row's blob — nothing to release.
|
|
49
58
|
if (keys.length === 0) return;
|
|
50
59
|
if (!fileProviderResolver) {
|
|
51
|
-
// skip: no resolver wired (files feature not mounted) — row still
|
|
52
|
-
// gets deleted below, but its FileRefs leak as storage orphans.
|
|
53
60
|
log?.warn?.(
|
|
54
61
|
`[form-draft:cleanup] tenant=${row.tenantId} has ${keys.length} FileRef(s) but no _fileProviderResolver is wired — row will be deleted without releasing storage`,
|
|
55
62
|
);
|
|
56
|
-
// skip:
|
|
63
|
+
// skip: no provider wired — cannot release storage; row deletion proceeds anyway.
|
|
57
64
|
return;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
// Only storageKeys with a real file_refs row owned by this row's draft
|
|
61
|
-
// owner are releasable — `draft.values` is free-form JSON the owning user
|
|
62
|
-
// controls, so an unverified key could target someone else's file (see
|
|
63
|
-
// db/queries/owned-file-refs.ts). Not wrapped in try/catch: a query
|
|
64
|
-
// failure here (pool exhaustion, missing table) is a real error, not the
|
|
65
|
-
// "no provider resolvable" case below — let it propagate so the job retries.
|
|
66
|
-
//
|
|
67
|
-
// Create-mode draftKey (`${screenId}:new:${draftId}`) mints its draftId
|
|
68
|
-
// lazily on the first step-change — a file uploaded before that exists
|
|
69
|
-
// predates the draft row, so the insertedAt filter must not apply.
|
|
70
67
|
const isCreateMode = row.draftKey.includes(":new:");
|
|
71
|
-
const
|
|
68
|
+
const ownedRefs = await filterOwnedFileRefs(
|
|
72
69
|
db,
|
|
73
70
|
row.tenantId,
|
|
74
71
|
row.ownerId,
|
|
@@ -76,10 +73,23 @@ async function releaseRowFileRefs(
|
|
|
76
73
|
row.insertedAt,
|
|
77
74
|
isCreateMode,
|
|
78
75
|
);
|
|
79
|
-
|
|
76
|
+
const systemUser = createSystemUser(row.tenantId);
|
|
77
|
+
const tenantDb = createTenantDb(db, row.tenantId, "system");
|
|
78
|
+
for (const ref of ownedRefs) {
|
|
79
|
+
const forgetResult = await fileRefExecutor.forget({ id: ref.id }, systemUser, tenantDb);
|
|
80
|
+
if (!forgetResult.isSuccess) {
|
|
81
|
+
log?.warn?.(
|
|
82
|
+
`[form-draft:cleanup] fileRef.forget failed for id=${ref.id} tenant=${row.tenantId}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
80
86
|
try {
|
|
81
87
|
const provider = await fileProviderResolver(row.tenantId);
|
|
82
|
-
await releaseDraftFileRefs(
|
|
88
|
+
await releaseDraftFileRefs(
|
|
89
|
+
ownedRefs.map((ref) => ref.storageKey),
|
|
90
|
+
(key) => provider.delete(key),
|
|
91
|
+
log,
|
|
92
|
+
);
|
|
83
93
|
} catch (err) {
|
|
84
94
|
log?.warn?.(
|
|
85
95
|
`[form-draft:cleanup] no file provider resolvable for tenant=${row.tenantId} — FileRefs NOT released (row still deleted): ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -132,9 +142,9 @@ async function deleteStaleDraftsBatch(
|
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
|
|
135
|
-
if (!ctx.db
|
|
145
|
+
if (!ctx.db) {
|
|
136
146
|
throw new InternalError({
|
|
137
|
-
message: "[form-draft:cleanup] ctx.db
|
|
147
|
+
message: "[form-draft:cleanup] ctx.db required (JobContext incomplete)",
|
|
138
148
|
});
|
|
139
149
|
}
|
|
140
150
|
const db = ctx.db as DbConnection;
|
|
@@ -163,6 +173,7 @@ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
|
|
|
163
173
|
|
|
164
174
|
let deleted = 0;
|
|
165
175
|
while (true) {
|
|
176
|
+
if (ctx.signal?.aborted) break;
|
|
166
177
|
const batch = await selectStaleDraftsBatch(db, retentionDays, DEFAULT_BATCH_SIZE);
|
|
167
178
|
if (batch.length === 0) break;
|
|
168
179
|
|
|
@@ -66,17 +66,17 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
66
66
|
existing.insertedAt,
|
|
67
67
|
isCreateMode,
|
|
68
68
|
);
|
|
69
|
-
|
|
69
|
+
// Fail-closed erase of file_refs rows FIRST (outside the best-effort
|
|
70
|
+
// storage-hygiene loop). releaseDraftFileRefs swallows per-key errors
|
|
71
|
+
// by design — routing forget through it would report discarded:true
|
|
72
|
+
// while leaving the row behind (#1922 / review #2009).
|
|
73
|
+
for (const ref of ownedRefs) {
|
|
74
|
+
const forgetResult = await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
|
|
75
|
+
if (!forgetResult.isSuccess) return forgetResult;
|
|
76
|
+
}
|
|
70
77
|
await releaseDraftFileRefs(
|
|
71
78
|
ownedRefs.map((ref) => ref.storageKey),
|
|
72
79
|
async (key) => {
|
|
73
|
-
// Hard-erase via the executor first (fileRef.forgotten — row +
|
|
74
|
-
// event, rebuild-safe), then the binary. A raw provider.delete()
|
|
75
|
-
// alone left the file_refs row behind forever (kumiko-framework
|
|
76
|
-
// review #1922): no event, no projection update, the row just
|
|
77
|
-
// pointed at a storage key that no longer existed.
|
|
78
|
-
const ref = refsByStorageKey.get(key);
|
|
79
|
-
if (ref !== undefined) await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
|
|
80
80
|
await files.ref(key).delete();
|
|
81
81
|
},
|
|
82
82
|
ctx.log,
|
|
@@ -569,4 +569,64 @@ describe("watch-supervisor — multi-worker watch coordination (#1719)", () => {
|
|
|
569
569
|
await supervisorB.stop();
|
|
570
570
|
}
|
|
571
571
|
});
|
|
572
|
+
|
|
573
|
+
test("losing the lease (renew fails) tears down the local watcher and lets the peer take over", async () => {
|
|
574
|
+
const admin = adminFor(4202);
|
|
575
|
+
const accountId = await connectSharedAccount(admin);
|
|
576
|
+
const lock = createDistributedLock(stack.redis.redis, "test-watch-lease-lost:");
|
|
577
|
+
const a = makeWatchIngestCounter();
|
|
578
|
+
const b = makeWatchIngestCounter();
|
|
579
|
+
const supervisorA = createInboundMailSupervisor({
|
|
580
|
+
providerCtx: { registry: stack.registry },
|
|
581
|
+
db,
|
|
582
|
+
dispatchWrite: a.dispatchWrite,
|
|
583
|
+
lock,
|
|
584
|
+
watchLeaseTtlSeconds: 1,
|
|
585
|
+
pollIntervalMs: 60_000,
|
|
586
|
+
});
|
|
587
|
+
const supervisorB = createInboundMailSupervisor({
|
|
588
|
+
providerCtx: { registry: stack.registry },
|
|
589
|
+
db,
|
|
590
|
+
dispatchWrite: b.dispatchWrite,
|
|
591
|
+
lock,
|
|
592
|
+
watchLeaseTtlSeconds: 1,
|
|
593
|
+
pollIntervalMs: 60_000,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
await supervisorA.start();
|
|
598
|
+
await waitFor(() => {
|
|
599
|
+
expect(isWatching(accountId)).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// B's own start() polls too but must not claim the watch lease — A
|
|
603
|
+
// already holds it. Priming `running` here (rather than only calling
|
|
604
|
+
// pollOnce() later) mirrors the "peer takes over" test above.
|
|
605
|
+
await supervisorB.start();
|
|
606
|
+
|
|
607
|
+
// Simulate the lease expiring behind A's back (Redis TTL lapse, or an
|
|
608
|
+
// operator flushing the key) by deleting it directly. A's next renew
|
|
609
|
+
// heartbeat (`lock.renew`, every ttl/3) then finds a token mismatch,
|
|
610
|
+
// reports failure, and must tear down A's local IDLE connection.
|
|
611
|
+
await stack.redis.redis.del(`test-watch-lease-lost:${accountId}`);
|
|
612
|
+
|
|
613
|
+
await waitFor(() => {
|
|
614
|
+
expect(isWatching(accountId)).toBe(false);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await supervisorB.pollOnce();
|
|
618
|
+
await waitFor(() => {
|
|
619
|
+
expect(isWatching(accountId)).toBe(true);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
await seedInboundMessage(accountId, rawMsg({ providerMessageId: "mw-lost-1" }));
|
|
623
|
+
await waitFor(() => {
|
|
624
|
+
expect(b.watchIngestCount()).toBe(1);
|
|
625
|
+
});
|
|
626
|
+
expect(a.watchIngestCount()).toBe(0);
|
|
627
|
+
} finally {
|
|
628
|
+
await supervisorA.stop();
|
|
629
|
+
await supervisorB.stop();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
572
632
|
});
|