@cosmicdrift/kumiko-bundled-features 0.208.2 → 0.209.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.
Files changed (36) hide show
  1. package/package.json +8 -8
  2. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +10 -2
  3. package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +50 -0
  4. package/src/compliance-profiles/changes.json +8 -1
  5. package/src/compliance-profiles/constants.ts +5 -0
  6. package/src/compliance-profiles/feature.ts +28 -3
  7. package/src/compliance-profiles/i18n.ts +25 -0
  8. package/src/compliance-profiles/web/client-plugin.tsx +7 -4
  9. package/src/compliance-profiles/web/compliance-profile-catalog.tsx +55 -0
  10. package/src/compliance-profiles/web/i18n.ts +1 -7
  11. package/src/compliance-profiles/web/index.ts +1 -1
  12. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +132 -0
  13. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -5
  14. package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -2
  15. package/src/delivery/__tests__/delivery.integration.test.ts +1 -1
  16. package/src/delivery/changes.json +6 -0
  17. package/src/delivery/constants.ts +1 -0
  18. package/src/delivery/feature.ts +28 -3
  19. package/src/delivery/handlers/log.query.ts +115 -18
  20. package/src/delivery/i18n.ts +4 -0
  21. package/src/delivery/public-names.ts +5 -0
  22. package/src/delivery/web/client-plugin.tsx +6 -7
  23. package/src/delivery/web/delivery-status-cell.tsx +26 -0
  24. package/src/delivery/web/index.ts +1 -1
  25. package/src/sessions/__tests__/session-last-seen.integration.test.ts +165 -0
  26. package/src/sessions/changes.json +6 -0
  27. package/src/sessions/constants.ts +4 -0
  28. package/src/sessions/feature.ts +17 -1
  29. package/src/sessions/schema/user-session.ts +5 -0
  30. package/src/sessions/session-callbacks.ts +20 -1
  31. package/src/sessions/session-checker-fail-open.test.ts +38 -0
  32. package/src/tenant-settings/__tests__/config.test.ts +19 -0
  33. package/src/tenant-settings/config.ts +5 -6
  34. package/src/compliance-profiles/web/compliance-profile-screen.tsx +0 -152
  35. package/src/delivery/web/delivery-log-screen.tsx +0 -103
  36. package/src/delivery/web/i18n.ts +0 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.208.2",
3
+ "version": "0.209.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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.208.2",
130
- "@cosmicdrift/kumiko-framework": "0.208.2",
131
- "@cosmicdrift/kumiko-headless": "0.208.2",
132
- "@cosmicdrift/kumiko-renderer": "0.208.2",
133
- "@cosmicdrift/kumiko-renderer-web": "0.208.2",
134
- "@cosmicdrift/kumiko-types": "0.208.2",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.209.0",
130
+ "@cosmicdrift/kumiko-framework": "0.209.0",
131
+ "@cosmicdrift/kumiko-headless": "0.209.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.209.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.209.0",
134
+ "@cosmicdrift/kumiko-types": "0.209.0",
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,6 @@
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.208.2"
163
+ "@cosmicdrift/kumiko-locale-de": "0.209.0"
164
164
  }
165
165
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { SELECTABLE_PROFILE_KEYS } from "@cosmicdrift/kumiko-framework/compliance";
2
3
  import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
5
  import { COMPLIANCE_PROFILE_SCREEN_ID, ComplianceProfileHandlers } from "../constants";
@@ -11,11 +12,18 @@ describe("compliance profile screen + handler access alignment", () => {
11
12
  expect(() => validateBoot(features)).not.toThrow();
12
13
  });
13
14
 
14
- test("profile-picker screen is custom, access.admin-gated", () => {
15
+ test("profile-picker screen is an actionForm wired to set-profile, access.admin-gated", () => {
15
16
  const feature = createComplianceProfilesFeature();
16
17
  const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
17
- expect(screen?.type).toBe("custom");
18
+ expect(screen?.type).toBe("actionForm");
18
19
  expect(rolesOf(screen?.access)).toEqual([...access.admin]);
20
+ if (screen?.type !== "actionForm") throw new Error("expected actionForm screen");
21
+ expect(screen.handler).toBe(ComplianceProfileHandlers.setProfile);
22
+ // Same choosable set the write-handler's zod schema enforces — a screen
23
+ // option outside SELECTABLE_PROFILE_KEYS would let a user pick a value
24
+ // the server always rejects.
25
+ const profileField = screen.fields["profileKey"] as { readonly options?: readonly string[] };
26
+ expect(profileField.options).toEqual([...SELECTABLE_PROFILE_KEYS]);
19
27
  });
20
28
 
21
29
  test("set-profile handler shares access.admin", () => {
@@ -89,3 +89,53 @@ describe("compliance profile HTTP access", () => {
89
89
  ).toBe(403);
90
90
  });
91
91
  });
92
+
93
+ describe("profile-picker actionForm screen: submit-path wiring", () => {
94
+ function pickerScreen() {
95
+ const feature = createComplianceProfilesFeature();
96
+ const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
97
+ if (screen?.type !== "actionForm") throw new Error("expected actionForm screen");
98
+ return screen;
99
+ }
100
+
101
+ test("submitting the screen's own handler with a screen-listed profile actually sets it", async () => {
102
+ const screen = pickerScreen();
103
+ const profileField = screen.fields["profileKey"] as { readonly options?: readonly string[] };
104
+ const chosen = profileField.options?.[1];
105
+ if (chosen === undefined) throw new Error("expected at least 2 selectable profiles");
106
+ const admin = createTestUser({ id: 34, roles: ["TenantAdmin"] });
107
+
108
+ const res = await stack.http.writeOk<{ profileKey: string }>(
109
+ screen.handler,
110
+ { profileKey: chosen },
111
+ admin,
112
+ );
113
+ expect(res.profileKey).toBe(chosen);
114
+
115
+ const current = await stack.http.queryOk<{ profile: { key: string } }>(
116
+ ComplianceProfileQueries.forTenant,
117
+ {},
118
+ admin,
119
+ );
120
+ expect(current.profile.key).toBe(chosen);
121
+ });
122
+
123
+ test("a profileKey outside the screen's declared options is rejected", async () => {
124
+ const screen = pickerScreen();
125
+ const admin = createTestUser({ id: 35, roles: ["TenantAdmin"] });
126
+ const res = await stack.http.write(
127
+ screen.handler,
128
+ { profileKey: "not-a-declared-profile" },
129
+ admin,
130
+ );
131
+ expect(res.status).toBeGreaterThanOrEqual(400);
132
+ });
133
+
134
+ test("a role outside the screen's access can't reach the handler it submits to", async () => {
135
+ const screen = pickerScreen();
136
+ const outsider = createTestUser({ id: 36, roles: ["User"] });
137
+ expect(rolesOf(screen.access)).not.toContain("User");
138
+ const res = await stack.http.write(screen.handler, { profileKey: "eu-dsgvo" }, outsider);
139
+ expect(res.status).toBe(403);
140
+ });
141
+ });
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.208.2",
4
+ "type": "improvement",
5
+ "title": "profile-picker screen switched from custom TSX to a declarative actionForm (fw#2222).",
6
+ "detail": "The 152-line custom compliance-profile-screen.tsx is gone. The picker is now an actionForm (select field submitting to the existing `compliance-profiles:write:set-profile` handler) plus an extension section for the read-only profile catalog (region + supervisory authority), now ~40 lines. Nav gained a `shield-check` icon."
7
+ }
8
+ ]
@@ -13,3 +13,8 @@ export const ComplianceProfileQueries = {
13
13
  } as const;
14
14
 
15
15
  export const COMPLIANCE_PROFILE_SCREEN_ID = "profile-picker" as const;
16
+
17
+ // Extension-section component name for the profile-catalog block under the
18
+ // picker's select field — registered client-side via
19
+ // complianceProfilesClient()'s extensionSectionComponents.
20
+ export const COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME = "ComplianceProfileCatalog" as const;
@@ -1,9 +1,14 @@
1
+ import { SELECTABLE_PROFILE_KEYS } from "@cosmicdrift/kumiko-framework/compliance";
1
2
  import {
2
3
  access,
3
4
  defineFeature,
4
5
  type FeatureDefinition,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
- import { COMPLIANCE_PROFILE_SCREEN_ID } from "./constants";
7
+ import {
8
+ COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME,
9
+ COMPLIANCE_PROFILE_SCREEN_ID,
10
+ ComplianceProfileHandlers,
11
+ } from "./constants";
7
12
  import { forTenantQuery } from "./handlers/for-tenant.query";
8
13
  import { listProfilesQuery } from "./handlers/list-profiles.query";
9
14
  import { createNeedsProfileQuery } from "./handlers/needs-profile.query";
@@ -75,14 +80,34 @@ export function createComplianceProfilesFeature(
75
80
 
76
81
  r.screen({
77
82
  id: COMPLIANCE_PROFILE_SCREEN_ID,
78
- type: "custom",
79
- renderer: { react: { __component: "ComplianceProfileScreen" } },
83
+ type: "actionForm",
84
+ handler: ComplianceProfileHandlers.setProfile,
85
+ fields: {
86
+ profileKey: { type: "select", options: SELECTABLE_PROFILE_KEYS, required: true },
87
+ },
88
+ layout: {
89
+ width: "full",
90
+ sections: [
91
+ { fields: ["profileKey"] },
92
+ {
93
+ kind: "extension",
94
+ title: "compliance-profiles:profile.catalog.title",
95
+ component: { react: { __component: COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME } },
96
+ },
97
+ ],
98
+ },
99
+ submitLabel: "compliance-profiles:profile.actions.save",
80
100
  access: { roles: resolvedAccess },
81
101
  });
82
102
  r.nav({
83
103
  id: "profile-picker",
84
104
  label: "compliance-profiles:nav.profilePicker",
85
105
  screen: "compliance-profiles:screen:profile-picker",
106
+ // "shield-check" (spec suggestion, fw#2222) isn't in NavIconKey's closed
107
+ // vocabulary (packages/types/src/nav-icon.ts) — adding it needs a
108
+ // matching lucide-react entry in renderer-web's NAV_ICONS map too, both
109
+ // outside this feature. "shield" is the closest already-registered key.
110
+ icon: "shield",
86
111
  order: 50,
87
112
  });
88
113
 
@@ -1,6 +1,31 @@
1
+ import {
2
+ COMPLIANCE_PROFILES,
3
+ SELECTABLE_PROFILE_KEYS,
4
+ } from "@cosmicdrift/kumiko-framework/compliance";
5
+
1
6
  type LocalizedString = { readonly en: string };
2
7
 
8
+ // Option labels for the actionForm's `profileKey` select — derived from
9
+ // SELECTABLE_PROFILE_KEYS so a new selectable profile picks up its i18n
10
+ // label automatically instead of needing a second hardcoded list here.
11
+ const PROFILE_OPTION_KEYS: Readonly<Record<string, LocalizedString>> = Object.fromEntries(
12
+ SELECTABLE_PROFILE_KEYS.map((key) => [
13
+ `compliance-profiles:entity:__action-form__:field:profileKey:option:${key}`,
14
+ { en: COMPLIANCE_PROFILES[key].label },
15
+ ]),
16
+ );
17
+
3
18
  export const COMPLIANCE_PROFILES_I18N: Readonly<Record<string, LocalizedString>> = {
4
19
  "screen:profile-picker.title": { en: "Compliance profile" },
5
20
  "compliance-profiles:nav.profilePicker": { en: "Compliance" },
21
+ // Field label + option labels for the actionForm's synthesized pseudo-
22
+ // entity (`__action-form__`, see action-form-shim.ts) — required by the
23
+ // i18n boot-validator (requiredKeysFromScreen) and must live in the
24
+ // server-registered translations, not the client bundle's own i18n: the
25
+ // declarative actionForm renderer resolves these through the schema
26
+ // payload, not through a custom screen component's client-side t().
27
+ "compliance-profiles:entity:__action-form__:field:profileKey": { en: "Compliance profile" },
28
+ "compliance-profiles:profile.actions.save": { en: "Save profile" },
29
+ "compliance-profiles:profile.catalog.title": { en: "Available profiles" },
30
+ ...PROFILE_OPTION_KEYS,
6
31
  };
@@ -1,8 +1,11 @@
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 { COMPLIANCE_PROFILE_SCREEN_ID, COMPLIANCE_PROFILES_FEATURE } from "../constants";
5
- import { ComplianceProfileScreen } from "./compliance-profile-screen";
4
+ import {
5
+ COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME,
6
+ COMPLIANCE_PROFILES_FEATURE,
7
+ } from "../constants";
8
+ import { ComplianceProfileCatalog } from "./compliance-profile-catalog";
6
9
  import { defaultTranslations } from "./i18n";
7
10
 
8
11
  export type ComplianceProfilesClientOptions = {
@@ -15,8 +18,8 @@ export function complianceProfilesClient(
15
18
  return {
16
19
  name: COMPLIANCE_PROFILES_FEATURE,
17
20
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
18
- components: {
19
- [COMPLIANCE_PROFILE_SCREEN_ID]: ComplianceProfileScreen,
21
+ extensionSectionComponents: {
22
+ [COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME]: ComplianceProfileCatalog,
20
23
  },
21
24
  };
22
25
  }
@@ -0,0 +1,55 @@
1
+ // @runtime client
2
+ // Extension-section component for the profile-picker actionForm's catalog
3
+ // block — read-only list of the selectable compliance profiles (region +
4
+ // supervisory authority). The select field above owns the actual choice;
5
+ // this is reference material for the tenant admin.
6
+ import {
7
+ type ExtensionSectionProps,
8
+ usePrimitives,
9
+ useQuery,
10
+ useTranslation,
11
+ } from "@cosmicdrift/kumiko-renderer";
12
+ import type { ReactNode } from "react";
13
+ import { ComplianceProfileQueries } from "../constants";
14
+
15
+ type ProfileSummary = {
16
+ readonly key: string;
17
+ readonly region: string;
18
+ readonly label: string;
19
+ readonly authorityContact: string;
20
+ };
21
+
22
+ type ListProfilesResponse = {
23
+ readonly profiles: readonly ProfileSummary[];
24
+ };
25
+
26
+ export function ComplianceProfileCatalog(_props: ExtensionSectionProps): ReactNode {
27
+ const { Banner, Text } = usePrimitives();
28
+ const t = useTranslation();
29
+ const query = useQuery<ListProfilesResponse>(ComplianceProfileQueries.listProfiles, {});
30
+
31
+ if (query.loading && query.data === null) {
32
+ return (
33
+ <Banner variant="loading" testId="compliance-profile-catalog-loading">
34
+ <Text>{t("compliance.profile.catalog.loading")}</Text>
35
+ </Banner>
36
+ );
37
+ }
38
+ if (query.error) {
39
+ return (
40
+ <Banner variant="error" testId="compliance-profile-catalog-error">
41
+ <Text>{t(query.error.i18nKey, query.error.i18nParams)}</Text>
42
+ </Banner>
43
+ );
44
+ }
45
+
46
+ return (
47
+ <ul className="flex flex-col gap-2 text-sm" data-testid="compliance-profile-catalog">
48
+ {(query.data?.profiles ?? []).map((profile) => (
49
+ <li key={profile.key} data-profile-key={profile.key}>
50
+ <strong>{profile.label}</strong> — {profile.region} — {profile.authorityContact}
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ );
55
+ }
@@ -3,13 +3,7 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
 
4
4
  export const defaultTranslations: TranslationsByLocale = {
5
5
  en: {
6
- "compliance.profile.title": "Compliance profile",
7
- "compliance.profile.loading": "Loading compliance profiles…",
8
- "compliance.profile.current": "Current",
9
- "compliance.profile.select": "Profile",
10
- "compliance.profile.save": "Save profile",
11
- "compliance.profile.saving": "Saving…",
12
- "compliance.profile.catalog": "Available profiles",
6
+ "compliance.profile.catalog.loading": "Loading compliance profiles…",
13
7
  "compliance-profiles:nav.profilePicker": "Compliance",
14
8
  },
15
9
  };
@@ -3,4 +3,4 @@ export {
3
3
  type ComplianceProfilesClientOptions,
4
4
  complianceProfilesClient,
5
5
  } from "./client-plugin";
6
- export { ComplianceProfileScreen } from "./compliance-profile-screen";
6
+ export { ComplianceProfileCatalog } from "./compliance-profile-catalog";
@@ -0,0 +1,132 @@
1
+ // delivery:query:log — PagedRows contract, real sort, sort-whitelist
2
+ // fallback, and the notificationType/recipientAddress → type/recipient
3
+ // field-mapping the query handler now owns (see log.query.ts).
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { insertMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
+ import {
8
+ createTestUser,
9
+ setupTestStack,
10
+ type TestStack,
11
+ testTenantId,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { DeliveryQueries } from "../constants";
15
+ import { createDeliveryFeature } from "../feature";
16
+ import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
17
+
18
+ type LogRow = {
19
+ readonly id: string;
20
+ readonly type: string;
21
+ readonly channel: string;
22
+ readonly recipient: string | null;
23
+ readonly status: string;
24
+ };
25
+
26
+ let stack: TestStack;
27
+ const tenantId = testTenantId(701);
28
+ const admin = createTestUser({ id: 701, roles: ["TenantAdmin"], tenantId });
29
+
30
+ beforeAll(async () => {
31
+ stack = await setupTestStack({ features: [createDeliveryFeature()] });
32
+ await unsafePushTables(stack.db, { deliveryAttemptsTable, notificationPreferencesTable });
33
+ await insertMany(stack.db, deliveryAttemptsTable, [
34
+ {
35
+ id: crypto.randomUUID(),
36
+ tenantId,
37
+ notificationType: "logtest:alpha",
38
+ channel: "email",
39
+ recipientAddress: "alpha@example.com",
40
+ status: "sent",
41
+ },
42
+ {
43
+ id: crypto.randomUUID(),
44
+ tenantId,
45
+ notificationType: "logtest:beta",
46
+ channel: "push",
47
+ recipientAddress: "beta@example.com",
48
+ status: "failed",
49
+ },
50
+ {
51
+ id: crypto.randomUUID(),
52
+ tenantId,
53
+ notificationType: "logtest:gamma",
54
+ channel: "inApp",
55
+ recipientAddress: null,
56
+ status: "queued",
57
+ },
58
+ ]);
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await stack.cleanup();
63
+ });
64
+
65
+ describe("delivery:query:log — PagedRows contract", () => {
66
+ test("a full page carries a nextCursor; the following (partial) page does not", async () => {
67
+ const first = await stack.http.queryOk<{
68
+ rows: readonly LogRow[];
69
+ nextCursor: string | null;
70
+ }>(DeliveryQueries.log, { limit: 2, sort: "type", sortDirection: "asc" }, admin);
71
+ expect(first.rows.map((r) => r.type)).toEqual(["logtest:alpha", "logtest:beta"]);
72
+ expect(first.nextCursor).not.toBeNull();
73
+
74
+ const second = await stack.http.queryOk<{
75
+ rows: readonly LogRow[];
76
+ nextCursor: string | null;
77
+ }>(
78
+ DeliveryQueries.log,
79
+ { limit: 2, sort: "type", sortDirection: "asc", cursor: first.nextCursor ?? undefined },
80
+ admin,
81
+ );
82
+ expect(second.rows.map((r) => r.type)).toEqual(["logtest:gamma"]);
83
+ expect(second.nextCursor).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe("delivery:query:log — sort", () => {
88
+ test("asc vs desc on the same whitelisted field actually reverses row order", async () => {
89
+ const asc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
90
+ DeliveryQueries.log,
91
+ { sort: "type", sortDirection: "asc" },
92
+ admin,
93
+ );
94
+ const desc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
95
+ DeliveryQueries.log,
96
+ { sort: "type", sortDirection: "desc" },
97
+ admin,
98
+ );
99
+ const ascTypes = asc.rows.map((r) => r.type);
100
+ const descTypes = desc.rows.map((r) => r.type);
101
+ expect(ascTypes).toEqual(["logtest:alpha", "logtest:beta", "logtest:gamma"]);
102
+ expect(descTypes).toEqual([...ascTypes].reverse());
103
+ });
104
+
105
+ test("a sort field outside the whitelist falls back to the default order, not an ad-hoc SQL column", async () => {
106
+ const withInvalidSort = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
107
+ DeliveryQueries.log,
108
+ { sort: "recipient" },
109
+ admin,
110
+ );
111
+ const withNoSort = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
112
+ DeliveryQueries.log,
113
+ {},
114
+ admin,
115
+ );
116
+ expect(withInvalidSort.rows.map((r) => r.id)).toEqual(withNoSort.rows.map((r) => r.id));
117
+ });
118
+ });
119
+
120
+ describe("delivery:query:log — field mapping", () => {
121
+ test("rows carry type/recipient (display names), not the raw notificationType/recipientAddress column names", async () => {
122
+ const result = await stack.http.queryOk<{ rows: readonly Record<string, unknown>[] }>(
123
+ DeliveryQueries.log,
124
+ { sort: "type", sortDirection: "asc" },
125
+ admin,
126
+ );
127
+ const alpha = result.rows.find((r) => r["type"] === "logtest:alpha");
128
+ expect(alpha?.["recipient"]).toBe("alpha@example.com");
129
+ expect(alpha).not.toHaveProperty("notificationType");
130
+ expect(alpha).not.toHaveProperty("recipientAddress");
131
+ });
132
+ });
@@ -171,7 +171,7 @@ describe("delivery attempt log under KMS", () => {
171
171
  const entry = before.rows.find(
172
172
  (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
173
173
  );
174
- expect(entry?.["recipientAddress"]).toBe(testEmail(recipient.id));
174
+ expect(entry?.["recipient"]).toBe(testEmail(recipient.id));
175
175
 
176
176
  await kms.eraseKey({ kind: "user", userId: recipient.id });
177
177
 
@@ -183,7 +183,7 @@ describe("delivery attempt log under KMS", () => {
183
183
  const erased = after.rows.find(
184
184
  (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
185
185
  );
186
- expect(erased?.["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
186
+ expect(erased?.["recipient"]).toBe(PII_ERASED_SENTINEL);
187
187
  });
188
188
 
189
189
  // money-horse#330: route:{email} (no user account, e.g. a share-token
@@ -224,7 +224,7 @@ describe("delivery attempt log under KMS", () => {
224
224
  );
225
225
  const otherSubjectBefore = beforeErase.rows.find(
226
226
  (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
227
- )?.["recipientAddress"];
227
+ )?.["recipient"];
228
228
  // Guard against a vacuous pass: if the row fell outside the `limit 100`
229
229
  // window, both sides below would be `undefined` and the "unaffected"
230
230
  // assertion would prove nothing.
@@ -241,13 +241,13 @@ describe("delivery attempt log under KMS", () => {
241
241
  const shredded = after.rows.find(
242
242
  (r) => r["recipientId"] === subjectId && r["channel"] === "email",
243
243
  );
244
- expect(shredded?.["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
244
+ expect(shredded?.["recipient"]).toBe(PII_ERASED_SENTINEL);
245
245
 
246
246
  // Other subjects (e.g. the plain `to:` recipient) are unaffected by the
247
247
  // share-token subject's erasure.
248
248
  const otherSubjectAfter = after.rows.find(
249
249
  (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
250
- )?.["recipientAddress"];
250
+ )?.["recipient"];
251
251
  expect(otherSubjectAfter).toBe(otherSubjectBefore);
252
252
  });
253
253
  });
@@ -11,10 +11,10 @@ describe("delivery screens + handler access alignment", () => {
11
11
  expect(() => validateBoot(features)).not.toThrow();
12
12
  });
13
13
 
14
- test("delivery-log screen is custom, access.admin-gated", () => {
14
+ test("delivery-log screen is projectionList, access.admin-gated", () => {
15
15
  const delivery = createDeliveryFeature();
16
16
  const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
17
- expect(screen?.type).toBe("custom");
17
+ expect(screen?.type).toBe("projectionList");
18
18
  expect(rolesOf(screen?.access)).toEqual([...access.admin]);
19
19
  });
20
20
 
@@ -515,7 +515,7 @@ describe("flow 3: broadcast to multiple users + markAllRead", () => {
515
515
 
516
516
  // 1 orderAssigned × 3 channels + 2 announcement × 3 channels = 9 total
517
517
  expect(result.rows.length).toBe(9);
518
- expect(result.rows[0]?.["notificationType"]).toBe("app:notify:announcement");
518
+ expect(result.rows[0]?.["type"]).toBe("app:notify:announcement");
519
519
  });
520
520
  });
521
521
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.208.2",
4
+ "type": "improvement",
5
+ "title": "`/delivery-log` is a declarative projectionList screen (#2221).",
6
+ "detail": "The 103-line hand-rolled `custom` screen (`delivery-log-screen.tsx`) is gone. `r.screen({ id: DELIVERY_LOG_SCREEN_ID, type: \"projectionList\", ... })` drives the list off `delivery:query:log`, now built on `definePagedQueryHandler` with `cursor`/`limit`/`sort`/`sortDirection` and a server-side sort whitelist (type-guarded, not an `as`-cast) covering `createdAt`/`type`/`channel`/`status` — `recipient` isn't sortable since it's PII decrypted per-row, not a column SQL can ORDER BY. Pagination is cursor-based (`pagination: \"infinite\"`): `deliveryAttemptsTable.id` is an event-stream aggregate id, not a sequential PK, so there's no cheap offset/COUNT(*) path. The notificationType/recipientAddress → type/recipient display-name mapping moved from the old client component into the query handler, matching the read-projection convention for declarative list screens; column i18n keys moved from the client-only bundle into the server-registered `DELIVERY_I18N` so the generic renderer can resolve them. The status column keeps one small TSX cell (`DeliveryStatusCell`, a `StatusBadge`), registered as a `columnRenderer` — that's the only surviving hand-written UI for this screen."
7
+ },
2
8
  {
3
9
  "version": "0.120.0",
4
10
  "type": "improvement",
@@ -6,6 +6,7 @@ export {
6
6
  DELIVERY_ATTEMPT_EVENT,
7
7
  DELIVERY_FEATURE,
8
8
  DELIVERY_LOG_SCREEN_ID,
9
+ DELIVERY_STATUS_CELL_COMPONENT,
9
10
  DeliveryErrors,
10
11
  DeliveryHandlers,
11
12
  DeliveryJobNames,
@@ -5,7 +5,13 @@ import {
5
5
  type FeatureDefinition,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import type { z } from "zod";
8
- import { DELIVERY_ATTEMPT_EVENT, DELIVERY_LOG_SCREEN_ID, DeliveryJobNames } from "./constants";
8
+ import {
9
+ DELIVERY_ATTEMPT_EVENT,
10
+ DELIVERY_LOG_SCREEN_ID,
11
+ DELIVERY_STATUS_CELL_COMPONENT,
12
+ DeliveryJobNames,
13
+ DeliveryQueries,
14
+ } from "./constants";
9
15
  import { deliveryAttemptSchema } from "./events";
10
16
  import { logQuery } from "./handlers/log.query";
11
17
  import { preferencesQuery } from "./handlers/preferences.query";
@@ -134,13 +140,32 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
134
140
 
135
141
  r.screen({
136
142
  id: DELIVERY_LOG_SCREEN_ID,
137
- type: "custom",
138
- renderer: { react: { __component: "DeliveryLogScreen" } },
143
+ type: "projectionList",
144
+ query: DeliveryQueries.log,
145
+ columns: [
146
+ { field: "type", label: "delivery.log.col.type" },
147
+ { field: "channel", label: "delivery.log.col.channel" },
148
+ { field: "recipient", label: "delivery.log.col.recipient" },
149
+ {
150
+ field: "status",
151
+ label: "delivery.log.col.status",
152
+ renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
153
+ },
154
+ ],
155
+ // Cursor-based paging: deliveryAttemptsTable's id is an event-stream
156
+ // aggregate id (not a sequential PK), so the "pages" default (offset +
157
+ // COUNT(*)) has no cheap query-side implementation here — "infinite"
158
+ // matches what log.query.ts's cursor/limit/sort schema actually supports.
159
+ pagination: "infinite",
160
+ defaultSort: { field: "createdAt", dir: "desc" },
139
161
  access: { roles: resolvedAccess },
140
162
  });
141
163
  r.nav({
142
164
  id: "delivery-log",
143
165
  label: "delivery:nav.deliveryLog",
166
+ // "send" isn't in the closed NavIconKey vocabulary (packages/types/src/
167
+ // nav-icon.ts) — "bell" matches the feature's own notifications framing.
168
+ icon: "bell",
144
169
  screen: "delivery:screen:delivery-log",
145
170
  order: 40,
146
171
  });