@cosmicdrift/kumiko-bundled-features 0.220.0 → 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/auth-client.ts +1 -7
- 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/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- 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 +7 -6
- 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/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- 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/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- 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__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -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 +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- 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
- package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
|
@@ -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
|
|
@@ -568,7 +568,7 @@ describe("feature-toggles queries + audit automation", () => {
|
|
|
568
568
|
test("registered query reports metadata + override + effective for every feature", async () => {
|
|
569
569
|
runtime.apply("widget", false);
|
|
570
570
|
const data = await stack.http.queryOk<{
|
|
571
|
-
|
|
571
|
+
rows: Array<{
|
|
572
572
|
name: string;
|
|
573
573
|
toggleable: boolean;
|
|
574
574
|
default: boolean | null;
|
|
@@ -577,7 +577,7 @@ describe("feature-toggles queries + audit automation", () => {
|
|
|
577
577
|
effective: boolean | null;
|
|
578
578
|
}>;
|
|
579
579
|
}>("feature-toggles:query:registered", {}, admin);
|
|
580
|
-
const byName = new Map(data.
|
|
580
|
+
const byName = new Map(data.rows.map((i) => [i.name, i]));
|
|
581
581
|
|
|
582
582
|
expect(byName.get("widget")).toMatchObject({
|
|
583
583
|
toggleable: true,
|
|
@@ -11,11 +11,19 @@ describe("feature-toggles screen + handler access alignment", () => {
|
|
|
11
11
|
expect(() => validateBoot(features)).not.toThrow();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test("toggle-admin screen is SystemAdmin-gated", () => {
|
|
14
|
+
test("toggle-admin screen is projectionList and SystemAdmin-gated", () => {
|
|
15
15
|
const toggles = createFeatureTogglesFeature();
|
|
16
16
|
const screen = toggles.screens[TOGGLE_ADMIN_SCREEN_ID];
|
|
17
|
-
expect(screen?.type).toBe("
|
|
18
|
-
if (screen
|
|
17
|
+
expect(screen?.type).toBe("projectionList");
|
|
18
|
+
if (screen?.type !== "projectionList") throw new Error("expected projectionList");
|
|
19
|
+
expect(screen.query).toBe(FeatureToggleQueries.registered);
|
|
20
|
+
const toggleAction = screen.rowActions?.find((a) => a.id === "toggle");
|
|
21
|
+
if (toggleAction?.kind !== "writeHandler") throw new Error("expected a writeHandler rowAction");
|
|
22
|
+
expect(toggleAction.handler).toBe(FeatureToggleHandlers.set);
|
|
23
|
+
expect(toggleAction.payload).toEqual({
|
|
24
|
+
map: { featureName: "name", enabled: "nextEnabled" },
|
|
25
|
+
});
|
|
26
|
+
if ("access" in screen && screen.access && "roles" in screen.access) {
|
|
19
27
|
expect(screen.access.roles).toEqual(["SystemAdmin"]);
|
|
20
28
|
}
|
|
21
29
|
});
|
|
@@ -36,6 +36,7 @@ describe("feature-toggles access matrix", () => {
|
|
|
36
36
|
]);
|
|
37
37
|
const feature = createFeatureTogglesFeature();
|
|
38
38
|
const screen = feature.screens[TOGGLE_ADMIN_SCREEN_ID];
|
|
39
|
+
expect(screen?.type).toBe("projectionList");
|
|
39
40
|
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
40
41
|
expect(screen.access.roles).toEqual(["SystemAdmin"]);
|
|
41
42
|
}
|
|
@@ -49,12 +50,12 @@ describe("feature-toggles HTTP access", () => {
|
|
|
49
50
|
{ items?: readonly unknown[] } | { rows?: readonly unknown[] }
|
|
50
51
|
>(FeatureToggleQueries.list, {}, admin);
|
|
51
52
|
expect(typeof list).toBe("object");
|
|
52
|
-
const registered = await stack.http.queryOk<{
|
|
53
|
+
const registered = await stack.http.queryOk<{ rows: readonly unknown[] }>(
|
|
53
54
|
FeatureToggleQueries.registered,
|
|
54
55
|
{},
|
|
55
56
|
admin,
|
|
56
57
|
);
|
|
57
|
-
expect(Array.isArray(registered.
|
|
58
|
+
expect(Array.isArray(registered.rows)).toBe(true);
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
test("TenantAdmin gets 403 on list, registered, and set", async () => {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
FEATURE_TOGGLE_SET_EVENT_NAME,
|
|
4
|
+
FeatureToggleHandlers,
|
|
5
|
+
FeatureToggleQueries,
|
|
6
|
+
TOGGLE_ADMIN_SCREEN_ID,
|
|
7
|
+
} from "./constants";
|
|
3
8
|
import { featureToggleSetSchema } from "./events";
|
|
4
9
|
import { globalFeatureStateTableMeta } from "./global-feature-state-table";
|
|
5
10
|
import { listQuery } from "./handlers/list.query";
|
|
@@ -67,11 +72,26 @@ export function createFeatureTogglesFeature(
|
|
|
67
72
|
registered: r.queryHandler(registeredQuery),
|
|
68
73
|
};
|
|
69
74
|
|
|
70
|
-
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
71
75
|
r.screen({
|
|
72
76
|
id: TOGGLE_ADMIN_SCREEN_ID,
|
|
73
|
-
type: "
|
|
74
|
-
|
|
77
|
+
type: "projectionList",
|
|
78
|
+
query: FeatureToggleQueries.registered,
|
|
79
|
+
columns: [
|
|
80
|
+
{ field: "name", label: "feature-toggles.admin.col.feature" },
|
|
81
|
+
{ field: "defaultLabel", label: "feature-toggles.admin.col.default" },
|
|
82
|
+
{ field: "overrideLabel", label: "feature-toggles.admin.col.override" },
|
|
83
|
+
{ field: "effectiveLabel", label: "feature-toggles.admin.col.effective" },
|
|
84
|
+
],
|
|
85
|
+
rowActions: [
|
|
86
|
+
{
|
|
87
|
+
kind: "writeHandler",
|
|
88
|
+
id: "toggle",
|
|
89
|
+
label: "feature-toggles.admin.toggle",
|
|
90
|
+
handler: FeatureToggleHandlers.set,
|
|
91
|
+
payload: { map: { featureName: "name", enabled: "nextEnabled" } },
|
|
92
|
+
visible: { field: "toggleable", eq: true },
|
|
93
|
+
},
|
|
94
|
+
],
|
|
75
95
|
access: { roles: ["SystemAdmin"] },
|
|
76
96
|
});
|
|
77
97
|
r.nav({
|
|
@@ -12,6 +12,12 @@ import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
|
12
12
|
// Design: registry introspection (toggleable/default/requires) + a single
|
|
13
13
|
// DB read of overrides. No per-feature DB calls. Scales to however many
|
|
14
14
|
// features an app registers — currently tens, never thousands.
|
|
15
|
+
|
|
16
|
+
function formatFlag(value: boolean | null): string {
|
|
17
|
+
if (value === null) return "inherit";
|
|
18
|
+
return value ? "on" : "off";
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export const registeredQuery = defineQueryHandler({
|
|
16
22
|
name: "registered",
|
|
17
23
|
schema: z.object({}),
|
|
@@ -38,38 +44,43 @@ export const registeredQuery = defineQueryHandler({
|
|
|
38
44
|
// dokumentiert in DispatcherOptions.effectiveFeatures.
|
|
39
45
|
const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
|
|
40
46
|
|
|
41
|
-
const
|
|
47
|
+
const rows: Array<{
|
|
48
|
+
id: string;
|
|
42
49
|
name: string;
|
|
43
50
|
toggleable: boolean;
|
|
44
51
|
default: boolean | null;
|
|
45
52
|
override: boolean | null;
|
|
46
53
|
requires: readonly string[];
|
|
47
54
|
effective: boolean | null;
|
|
55
|
+
defaultLabel: string;
|
|
56
|
+
overrideLabel: string;
|
|
57
|
+
effectiveLabel: string;
|
|
58
|
+
nextEnabled: boolean;
|
|
48
59
|
}> = [];
|
|
60
|
+
|
|
49
61
|
for (const feature of ctx.registry.features.values()) {
|
|
50
62
|
const toggleable = feature.toggleableDefault !== undefined;
|
|
51
63
|
const override = overrides.get(feature.name);
|
|
52
|
-
|
|
64
|
+
const defaultValue = feature.toggleableDefault ?? null;
|
|
65
|
+
const effectiveValue = effective ? effective.has(feature.name) : null;
|
|
66
|
+
const effectiveBool = effectiveValue ?? defaultValue ?? false;
|
|
67
|
+
|
|
68
|
+
rows.push({
|
|
69
|
+
id: feature.name,
|
|
53
70
|
name: feature.name,
|
|
54
71
|
toggleable,
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
// control (flipping them would be rejected by the set-handler).
|
|
58
|
-
default: feature.toggleableDefault ?? null,
|
|
59
|
-
// `override` is null when no explicit row exists. That's distinct
|
|
60
|
-
// from "override says on" or "override says off" so the UI can show
|
|
61
|
-
// an "inherits default" indicator.
|
|
72
|
+
// Raw booleans for API consumers; label fields for projectionList columns.
|
|
73
|
+
default: defaultValue,
|
|
62
74
|
override: override ?? null,
|
|
63
75
|
requires: feature.requires,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
effective: effective ? effective.has(feature.name) : null,
|
|
76
|
+
effective: effectiveValue,
|
|
77
|
+
defaultLabel: formatFlag(defaultValue),
|
|
78
|
+
overrideLabel: formatFlag(override ?? null),
|
|
79
|
+
effectiveLabel: formatFlag(effectiveValue),
|
|
80
|
+
nextEnabled: !effectiveBool,
|
|
70
81
|
});
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
return {
|
|
84
|
+
return { rows, nextCursor: null };
|
|
74
85
|
},
|
|
75
86
|
});
|
|
@@ -3,4 +3,9 @@ type LocalizedString = { readonly en: string };
|
|
|
3
3
|
export const FEATURE_TOGGLES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
4
|
"screen:toggle-admin.title": { en: "Feature toggles" },
|
|
5
5
|
"feature-toggles:nav.toggleAdmin": { en: "Feature toggles" },
|
|
6
|
+
"feature-toggles.admin.col.feature": { en: "Feature" },
|
|
7
|
+
"feature-toggles.admin.col.default": { en: "Default" },
|
|
8
|
+
"feature-toggles.admin.col.override": { en: "Override" },
|
|
9
|
+
"feature-toggles.admin.col.effective": { en: "Effective" },
|
|
10
|
+
"feature-toggles.admin.toggle": { en: "Toggle" },
|
|
6
11
|
};
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import { FEATURE_TOGGLES_FEATURE
|
|
4
|
+
import { FEATURE_TOGGLES_FEATURE } from "../constants";
|
|
5
5
|
import { defaultTranslations } from "./i18n";
|
|
6
|
-
import { ToggleAdminScreen } from "./toggle-admin-screen";
|
|
7
6
|
|
|
8
7
|
export type FeatureTogglesClientOptions = {
|
|
9
8
|
readonly translations?: TranslationsByLocale;
|
|
@@ -15,8 +14,5 @@ export function featureTogglesClient(
|
|
|
15
14
|
return {
|
|
16
15
|
name: FEATURE_TOGGLES_FEATURE,
|
|
17
16
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
18
|
-
components: {
|
|
19
|
-
[TOGGLE_ADMIN_SCREEN_ID]: ToggleAdminScreen,
|
|
20
|
-
},
|
|
21
17
|
};
|
|
22
18
|
}
|
|
@@ -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);
|