@cosmicdrift/kumiko-bundled-features 0.215.6 → 0.215.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.215.6",
3
+ "version": "0.215.7",
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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.215.6",
130
- "@cosmicdrift/kumiko-framework": "0.215.6",
131
- "@cosmicdrift/kumiko-headless": "0.215.6",
132
- "@cosmicdrift/kumiko-renderer": "0.215.6",
133
- "@cosmicdrift/kumiko-renderer-web": "0.215.6",
134
- "@cosmicdrift/kumiko-types": "0.215.6",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.215.7",
130
+ "@cosmicdrift/kumiko-framework": "0.215.7",
131
+ "@cosmicdrift/kumiko-headless": "0.215.7",
132
+ "@cosmicdrift/kumiko-renderer": "0.215.7",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.215.7",
134
+ "@cosmicdrift/kumiko-types": "0.215.7",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,6 +160,7 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.215.6"
163
+ "@cosmicdrift/kumiko-locale-de": "0.215.7",
164
+ "@cosmicdrift/kumiko-locale-es": "0.215.7"
164
165
  }
165
166
  }
@@ -11,6 +11,10 @@ describe("cap-counter list screen + handler access alignment", () => {
11
11
  expect(() => validateBoot(features)).not.toThrow();
12
12
  });
13
13
 
14
+ test("cap-list nav declares icon gauge", () => {
15
+ expect(capCounterFeature.navs["cap-list"]?.icon).toBe("gauge");
16
+ });
17
+
14
18
  test("cap-list screen is entityList, SystemAdmin-gated", () => {
15
19
  const screen = capCounterFeature.screens[CAP_COUNTER_LIST_SCREEN_ID];
16
20
  expect(screen?.type).toBe("entityList");
@@ -110,6 +110,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
110
110
  id: "cap-list",
111
111
  label: "cap-counter:nav.cap-list",
112
112
  screen: "cap-counter:screen:cap-list",
113
+ icon: "gauge",
113
114
  order: 60,
114
115
  });
115
116
  r.translations({
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { insertMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
- import { access, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { access, createRegistry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import {
5
5
  createTestUser,
6
6
  setupTestStack,
@@ -105,6 +105,144 @@ describe("delivery log tenant isolation", () => {
105
105
  });
106
106
  });
107
107
 
108
+ describe("delivery log SystemAdmin cross-tenant scope", () => {
109
+ // Platform waitlist confirmations are dispatched under SYSTEM_TENANT_ID
110
+ // (anonymous submit). SystemAdmin must see those rows even when their
111
+ // session tenant is a demo tenant; TenantAdmin must not.
112
+ const WAITLIST_CONFIRMATION = "waitlist:notify:confirmation";
113
+
114
+ test("SystemAdmin sees waitlist confirmation under SYSTEM_TENANT_ID and other tenants, with tenantId on each row", async () => {
115
+ const demoTenant = testTenantId(51);
116
+ const otherTenant = testTenantId(52);
117
+ const systemConfirmationId = crypto.randomUUID();
118
+ const demoActivationId = crypto.randomUUID();
119
+ const otherTenantId = crypto.randomUUID();
120
+ const failedConfirmationId = crypto.randomUUID();
121
+
122
+ await insertMany(stack.db, deliveryAttemptsTable, [
123
+ {
124
+ id: systemConfirmationId,
125
+ tenantId: SYSTEM_TENANT_ID,
126
+ notificationType: WAITLIST_CONFIRMATION,
127
+ channel: "email",
128
+ recipientAddress: null,
129
+ status: "sent",
130
+ },
131
+ {
132
+ id: failedConfirmationId,
133
+ tenantId: SYSTEM_TENANT_ID,
134
+ notificationType: WAITLIST_CONFIRMATION,
135
+ channel: "email",
136
+ recipientAddress: null,
137
+ status: "failed",
138
+ error: "smtp down",
139
+ },
140
+ {
141
+ id: demoActivationId,
142
+ tenantId: demoTenant,
143
+ notificationType: "auth-email-password:signup-activation",
144
+ channel: "email",
145
+ recipientAddress: null,
146
+ status: "sent",
147
+ },
148
+ {
149
+ id: otherTenantId,
150
+ tenantId: otherTenant,
151
+ notificationType: "welcome",
152
+ channel: "email",
153
+ recipientAddress: null,
154
+ status: "sent",
155
+ },
156
+ ]);
157
+
158
+ // Session on demo tenant — mirrors SystemAdmin browsing a tenant panel.
159
+ const systemAdmin = createTestUser({
160
+ id: 51,
161
+ roles: ["SystemAdmin"],
162
+ tenantId: demoTenant,
163
+ });
164
+ const res = await stack.http.queryOk<{
165
+ rows: readonly {
166
+ id: string;
167
+ tenantId: string;
168
+ type: string;
169
+ status: string;
170
+ error: string | null;
171
+ }[];
172
+ }>(DeliveryQueries.log, {}, systemAdmin);
173
+
174
+ const byId = new Map(res.rows.map((r) => [r.id, r]));
175
+ expect(byId.get(systemConfirmationId)?.tenantId).toBe(SYSTEM_TENANT_ID);
176
+ expect(byId.get(systemConfirmationId)?.type).toBe(WAITLIST_CONFIRMATION);
177
+ expect(byId.get(demoActivationId)?.tenantId).toBe(demoTenant);
178
+ expect(byId.get(otherTenantId)?.tenantId).toBe(otherTenant);
179
+
180
+ const failed = byId.get(failedConfirmationId);
181
+ expect(failed?.tenantId).toBe(SYSTEM_TENANT_ID);
182
+ expect(failed?.type).toBe(WAITLIST_CONFIRMATION);
183
+ // Failure path: confirmation delivery errors must surface in the log so
184
+ // SystemAdmin can diagnose SMTP/channel outages (status=failed).
185
+ expect(failed?.status).toBe("failed");
186
+
187
+ expect(res.rows.every((r) => typeof r.tenantId === "string" && r.tenantId.length > 0)).toBe(
188
+ true,
189
+ );
190
+ });
191
+
192
+ test("TenantAdmin cannot read SYSTEM_TENANT_ID or foreign-tenant delivery attempts", async () => {
193
+ const demoTenant = testTenantId(61);
194
+ const otherTenant = testTenantId(62);
195
+ await insertMany(stack.db, deliveryAttemptsTable, [
196
+ {
197
+ id: crypto.randomUUID(),
198
+ tenantId: SYSTEM_TENANT_ID,
199
+ notificationType: WAITLIST_CONFIRMATION,
200
+ channel: "email",
201
+ recipientAddress: null,
202
+ status: "sent",
203
+ },
204
+ {
205
+ id: crypto.randomUUID(),
206
+ tenantId: otherTenant,
207
+ notificationType: "welcome",
208
+ channel: "email",
209
+ recipientAddress: null,
210
+ status: "sent",
211
+ },
212
+ {
213
+ id: crypto.randomUUID(),
214
+ tenantId: demoTenant,
215
+ notificationType: "welcome",
216
+ channel: "email",
217
+ recipientAddress: null,
218
+ status: "sent",
219
+ },
220
+ ]);
221
+
222
+ const tenantAdmin = createTestUser({
223
+ id: 61,
224
+ roles: ["TenantAdmin"],
225
+ tenantId: demoTenant,
226
+ });
227
+ const res = await stack.http.queryOk<{ rows: readonly { tenantId: string }[] }>(
228
+ DeliveryQueries.log,
229
+ {},
230
+ tenantAdmin,
231
+ );
232
+
233
+ expect(res.rows.length).toBeGreaterThan(0);
234
+ expect(res.rows.every((r) => r.tenantId === demoTenant)).toBe(true);
235
+ expect(res.rows.some((r) => r.tenantId === SYSTEM_TENANT_ID)).toBe(false);
236
+ expect(res.rows.some((r) => r.tenantId === otherTenant)).toBe(false);
237
+ });
238
+
239
+ test("tenant User cannot query delivery log (negative authz)", async () => {
240
+ const demoTenant = testTenantId(71);
241
+ const user = createTestUser({ id: 71, roles: ["User"], tenantId: demoTenant });
242
+ expect((await stack.http.query(DeliveryQueries.log, {}, user)).status).toBe(403);
243
+ });
244
+ });
245
+
108
246
  describe("delivery preferences tenant isolation", () => {
109
247
  // A userId is not tenant-unique — the same person can be a member of
110
248
  // several tenants (tenant:write:add-member takes {userId, tenantId}).
@@ -143,6 +143,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
143
143
  type: "projectionList",
144
144
  query: DeliveryQueries.log,
145
145
  columns: [
146
+ { field: "tenantId", label: "delivery.log.col.tenantId" },
146
147
  { field: "type", label: "delivery.log.col.type" },
147
148
  { field: "channel", label: "delivery.log.col.channel" },
148
149
  { field: "recipient", label: "delivery.log.col.recipient" },
@@ -81,8 +81,15 @@ export const logQuery = definePagedQueryHandler({
81
81
  // delivery is r.systemScope()'d, so the TenantDb is system-mode (reads
82
82
  // unfiltered). assertTenantMatch is a self-check on the caller's own
83
83
  // tenantId, not a query filter — it returns that same unfiltered db, so
84
- // the explicit `where` below still does the actual tenant scoping.
85
- const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
84
+ // the explicit `where` below still does the actual tenant scoping for
85
+ // non-SystemAdmin callers. SystemAdmin acknowledges cross-tenant read
86
+ // (PII-bearing delivery logs across all tenants incl. SYSTEM_TENANT_ID).
87
+ const isSystemAdmin = query.user.roles.includes("SystemAdmin");
88
+ const db = isSystemAdmin
89
+ ? ctx.systemDb.acknowledgeCrossTenant(
90
+ "SystemAdmin reads delivery attempts across all tenants including SYSTEM_TENANT_ID",
91
+ )
92
+ : ctx.systemDb.assertTenantMatch(query.user.tenantId);
86
93
 
87
94
  const requestedSort = query.payload.sort;
88
95
  const sortField: DeliveryLogSortField =
@@ -92,7 +99,9 @@ export const logQuery = definePagedQueryHandler({
92
99
  const sortDirection = query.payload.sortDirection ?? "desc";
93
100
  const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
94
101
 
95
- const where: WhereObject = { tenantId: query.user.tenantId };
102
+ // TenantAdmin/Admin stay strictly tenant-scoped; SystemAdmin sees every
103
+ // tenant's attempts (platform waitlist confirmations live on SYSTEM_TENANT_ID).
104
+ const where: WhereObject = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
96
105
  if (query.payload.cursor) {
97
106
  where[sortColumn] = {
98
107
  [sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
@@ -3,6 +3,7 @@ type LocalizedString = { readonly en: string };
3
3
  export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:delivery-log.title": { en: "Delivery log" },
5
5
  "delivery:nav.deliveryLog": { en: "Delivery" },
6
+ "delivery.log.col.tenantId": { en: "Tenant" },
6
7
  "delivery.log.col.type": { en: "Type" },
7
8
  "delivery.log.col.channel": { en: "Channel" },
8
9
  "delivery.log.col.recipient": { en: "Recipient" },
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isManualTrigger } from "./is-manual-trigger";
3
+
4
+ describe("isManualTrigger", () => {
5
+ test("manual trigger → true", () => {
6
+ expect(isManualTrigger({ manual: true })).toBe(true);
7
+ });
8
+
9
+ test("cron trigger → false", () => {
10
+ expect(isManualTrigger({ cron: "*/5 * * * *" })).toBe(false);
11
+ });
12
+
13
+ test("event trigger → false", () => {
14
+ expect(isManualTrigger({ on: "entity.created" })).toBe(false);
15
+ expect(isManualTrigger({ on: ["entity.created", "entity.updated"] })).toBe(false);
16
+ });
17
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sessionLocaleField } from "./session-locale-field";
3
+
4
+ describe("sessionLocaleField", () => {
5
+ test("set locale → { locale }", () => {
6
+ expect(sessionLocaleField("de-DE")).toEqual({ locale: "de-DE" });
7
+ });
8
+
9
+ test("null locale → empty object", () => {
10
+ expect(sessionLocaleField(null)).toEqual({});
11
+ });
12
+
13
+ test("undefined locale → empty object", () => {
14
+ expect(sessionLocaleField(undefined)).toEqual({});
15
+ });
16
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sessionTimezoneField } from "./session-timezone-field";
3
+
4
+ describe("sessionTimezoneField", () => {
5
+ test("set timezone → { timezone }", () => {
6
+ expect(sessionTimezoneField("Europe/Berlin")).toEqual({ timezone: "Europe/Berlin" });
7
+ });
8
+
9
+ test("null timezone → empty object", () => {
10
+ expect(sessionTimezoneField(null)).toEqual({});
11
+ });
12
+
13
+ test("undefined timezone → empty object", () => {
14
+ expect(sessionTimezoneField(undefined)).toEqual({});
15
+ });
16
+ });
@@ -0,0 +1,72 @@
1
+ // fw#2376: the Members facet labels are i18n keys that the renderer resolves
2
+ // through translate() (fw#2373). A key missing from a locale pack resolves to
3
+ // nothing and the raw key ("tenant.members.filter.status") ends up in the UI —
4
+ // which is exactly what shipping translate() without the es/de entries did.
5
+ // The keys are read off the live screen definition instead of being hardcoded,
6
+ // so adding a facet option without translating it fails here rather than in
7
+ // production.
8
+ import { describe, expect, test } from "bun:test";
9
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
10
+ import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
11
+ import { MEMBERS_SCREEN_ID } from "../constants";
12
+ import { createTenantFeature } from "../feature";
13
+
14
+ function membersFacetLabelKeys(): readonly string[] {
15
+ const screen = createTenantFeature().screens[MEMBERS_SCREEN_ID];
16
+ if (screen?.type !== "projectionList") {
17
+ throw new Error("expected members screen to be projectionList");
18
+ }
19
+ return (screen.facets ?? []).flatMap((facet) =>
20
+ facet.type === "select"
21
+ ? [facet.label, ...facet.options.map((option) => option.label)]
22
+ : [facet.label, facet.trueLabel, facet.falseLabel],
23
+ );
24
+ }
25
+
26
+ const facetKeys = membersFacetLabelKeys();
27
+
28
+ const localePacks: ReadonlyArray<readonly [string, Readonly<Record<string, string>>]> = [
29
+ ["es", localeEsBundle],
30
+ ["de", localeDeBundle],
31
+ ];
32
+
33
+ describe("members status filter labels are localized in every shipped locale (fw#2376)", () => {
34
+ test("the status facet ships the keys the locale packs have to cover", () => {
35
+ expect(facetKeys).toEqual([
36
+ "tenant.members.filter.status",
37
+ "tenant.members.filter.status.option.active",
38
+ "tenant.members.filter.status.option.pending",
39
+ ]);
40
+ });
41
+
42
+ test("every facet label has English copy in the tenant feature itself", () => {
43
+ const translations = createTenantFeature().translations ?? {};
44
+ for (const key of facetKeys) {
45
+ const label = translations[key]?.["en"];
46
+ expect(label).toBeString();
47
+ expect(label).not.toBe(key);
48
+ expect((label ?? "").trim()).not.toBe("");
49
+ }
50
+ });
51
+
52
+ for (const [locale, bundle] of localePacks) {
53
+ test(`every facet label resolves to a localized string in ${locale}`, () => {
54
+ for (const key of facetKeys) {
55
+ const label = bundle[key];
56
+ expect(label).toBeString();
57
+ expect(label).not.toBe(key);
58
+ expect((label ?? "").trim()).not.toBe("");
59
+ }
60
+ });
61
+ }
62
+
63
+ // Presence alone would still pass if a pack copied the English string in,
64
+ // which is the failure mode a "did you actually translate it" check has to
65
+ // catch for the two option labels users read in the dropdown.
66
+ test("the option labels are real translations, not the English copy", () => {
67
+ expect(localeEsBundle["tenant.members.filter.status.option.active"]).toBe("Activo");
68
+ expect(localeEsBundle["tenant.members.filter.status.option.pending"]).toBe("Pendiente");
69
+ expect(localeDeBundle["tenant.members.filter.status.option.active"]).toBe("Aktiv");
70
+ expect(localeDeBundle["tenant.members.filter.status.option.pending"]).toBe("Ausstehend");
71
+ });
72
+ });