@cosmicdrift/kumiko-samples 0.383.1 → 0.385.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 (83) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/event-classification.ts +83 -0
  5. package/packages/bundled-features/src/audit/feature.ts +3 -2
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +63 -5
  7. package/packages/bundled-features/src/audit/handlers/details-all-tenants.query.ts +6 -5
  8. package/packages/bundled-features/src/audit/handlers/details.query.ts +12 -6
  9. package/packages/bundled-features/src/audit/handlers/list-all-tenants.query.ts +20 -12
  10. package/packages/bundled-features/src/audit/handlers/list.query.ts +14 -3
  11. package/packages/bundled-features/src/audit/screens.ts +32 -4
  12. package/packages/bundled-features/src/audit/web/audit-event-cell.tsx +22 -0
  13. package/packages/bundled-features/src/audit/web/audit-event-labels.ts +30 -0
  14. package/packages/bundled-features/src/audit/web/client-plugin.tsx +10 -1
  15. package/packages/bundled-features/src/audit/web/constants.ts +11 -0
  16. package/packages/bundled-features/src/audit/web/i18n.ts +48 -1
  17. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  18. package/packages/bundled-features/src/auth-email-password/feature.ts +3 -2
  19. package/packages/bundled-features/src/auth-email-password/handlers/login-options.query.ts +19 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +7 -1
  21. package/packages/bundled-features/src/auth-email-password/password-policy.ts +196 -2087
  22. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +9 -12
  23. package/packages/bundled-features/src/auth-email-password/{auth-paths.ts → web/auth-paths.ts} +7 -4
  24. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -0
  25. package/packages/bundled-features/src/auth-email-password/web/constants.ts +1 -0
  26. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +31 -0
  27. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +68 -42
  28. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +1 -1
  29. package/packages/bundled-features/src/custom-fields/db/queries/projection.ts +1 -1
  30. package/packages/bundled-features/src/file-derivatives/feature.ts +1 -1
  31. package/packages/bundled-features/src/jobs/changes.json +6 -0
  32. package/packages/bundled-features/src/jobs/feature.ts +34 -127
  33. package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +98 -0
  34. package/packages/bundled-features/src/jobs/handlers/consumers.query.ts +137 -0
  35. package/packages/bundled-features/src/jobs/screens.ts +262 -0
  36. package/packages/bundled-features/src/jobs/web/constants.ts +29 -0
  37. package/packages/bundled-features/src/jobs/web/i18n.ts +41 -0
  38. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  39. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  40. package/packages/bundled-features/src/personal-access-tokens/changes.json +7 -0
  41. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +4 -0
  42. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -0
  43. package/packages/bundled-features/src/secrets/i18n.ts +0 -3
  44. package/packages/bundled-features/src/sessions/changes.json +6 -0
  45. package/packages/bundled-features/src/sessions/constants.ts +3 -1
  46. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +7 -3
  47. package/packages/bundled-features/src/sessions/handlers/list.query.ts +16 -10
  48. package/packages/bundled-features/src/sessions/handlers/mine.query.ts +15 -9
  49. package/packages/bundled-features/src/sessions/screens.ts +23 -8
  50. package/packages/bundled-features/src/sessions/web/client-plugin.tsx +17 -0
  51. package/packages/bundled-features/src/sessions/web/constants.ts +3 -0
  52. package/packages/bundled-features/src/sessions/web/default-translations.ts +8 -0
  53. package/packages/bundled-features/src/sessions/web/i18n.ts +5 -1
  54. package/packages/bundled-features/src/sessions/web/index.ts +1 -0
  55. package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +19 -0
  56. package/packages/bundled-features/src/sessions/web/user-agent.ts +74 -0
  57. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +1 -1
  58. package/packages/bundled-features/src/template-resolver/changes.json +6 -0
  59. package/packages/bundled-features/src/template-resolver/feature.ts +2 -0
  60. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +34 -1
  61. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +53 -0
  62. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +51 -0
  63. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +7 -38
  64. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  65. package/packages/bundled-features/src/user-data-rights/changes.json +18 -0
  66. package/packages/bundled-features/src/user-data-rights/feature.ts +33 -33
  67. package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -1
  68. package/samples/apps/showcase/src/features/items/schema/item.ts +8 -2
  69. package/samples/apps/ui-walkthrough/src/features/tasks/schema/task.ts +2 -2
  70. package/samples/apps/use-all-bundled/bin/main.ts +7 -1
  71. package/samples/apps/use-all-bundled/feature-manifest.json +19 -1
  72. package/samples/apps/use-all-bundled/kumiko/schema.ts +3 -1
  73. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  74. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +4 -2
  75. package/samples/apps/use-all-bundled/src/app/invoice-reports-feature.ts +30 -0
  76. package/samples/apps/use-all-bundled/src/app/pat-scope-labels-feature.ts +21 -0
  77. package/samples/apps/use-all-bundled/src/app/seed.ts +51 -0
  78. package/samples/apps/use-all-bundled/src/run-config.ts +5 -0
  79. package/samples/apps/workspaces/src/features/demo/schema/order.ts +2 -2
  80. package/samples/recipes/custom-fields-basic/src/feature.ts +13 -3
  81. package/samples/recipes/designer-demo/src/feature.ts +6 -2
  82. package/samples/recipes/i18n/README.md +15 -0
  83. package/samples/recipes/i18n/src/feature.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.383.1",
3
+ "version": "0.385.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.383.1",
3
+ "version": "0.385.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>",
@@ -122,6 +122,7 @@
122
122
  "./presets": "./src/presets/index.ts",
123
123
  "./secrets": "./src/secrets/index.ts",
124
124
  "./sessions": "./src/sessions/index.ts",
125
+ "./sessions/web": "./src/sessions/web/index.ts",
125
126
  "./auth-mfa": "./src/auth-mfa/index.ts",
126
127
  "./auth-mfa-user-data": "./src/auth-mfa-user-data/index.ts",
127
128
  "./auth-mfa/web": "./src/auth-mfa/web/index.ts",
@@ -594,6 +595,10 @@
594
595
  "types": "./dist/sessions/index.d.ts",
595
596
  "default": "./dist/sessions/index.js"
596
597
  },
598
+ "./sessions/web": {
599
+ "types": "./dist/sessions/web/index.d.ts",
600
+ "default": "./dist/sessions/web/index.js"
601
+ },
597
602
  "./auth-mfa": {
598
603
  "types": "./dist/auth-mfa/index.d.ts",
599
604
  "default": "./dist/auth-mfa/index.js"
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "improvement",
5
+ "title": "Audit log shows a label for every event type and hides technical entries",
6
+ "detail": "Events that are not entity lifecycle events get a readable label (for example \"Custom field value set\") or are classified technical (delivery attempts, counters, tenant-destruction stages) and hidden by default behind \"Show technical entries\". New event types of bundled features need an entry in the audit classification; a test over all registered events fails without one. Apps that add their own event types are unaffected, they keep the entity and action wording or the raw type."
7
+ },
2
8
  {
3
9
  "version": "0.383.0",
4
10
  "type": "improvement",
@@ -0,0 +1,83 @@
1
+ // Events that are not entity lifecycle events (`<entity>.created` etc.) need an explicit
2
+ // audit stance: a label key for the log, or "technical" to hide them behind the facet.
3
+ // The boot-registry test fails for a bundled event type that has neither.
4
+
5
+ import { WORKFLOW_SYSTEM_EVENT_DEFS } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { ESCAPE_HATCH_USED_EVENT } from "./escape-hatch-audit-sink.ts";
7
+
8
+ export const TECHNICAL_EVENT = "technical" as const;
9
+
10
+ export type AuditEventStance = typeof TECHNICAL_EVENT | { readonly labelKey: string };
11
+
12
+ const label = (name: string): AuditEventStance => ({ labelKey: `audit.log.event.${name}` });
13
+
14
+ export const AUDIT_EVENT_STANCES: Readonly<Record<string, AuditEventStance>> = {
15
+ [ESCAPE_HATCH_USED_EVENT]: TECHNICAL_EVENT,
16
+ "delivery:event:attempt": TECHNICAL_EVENT,
17
+ "delivery:event:attempt-address-erased": TECHNICAL_EVENT,
18
+ "cap-counter:event:rolling-incremented": TECHNICAL_EVENT,
19
+ "cap-counter:event:rolling-released": TECHNICAL_EVENT,
20
+ "inbound-mail-foundation:event:inbound-message-received": TECHNICAL_EVENT,
21
+ "inbound-mail-foundation:event:mail-thread-updated": TECHNICAL_EVENT,
22
+ "document-ingest-foundation:event:document-ingest-requested": TECHNICAL_EVENT,
23
+ "document-ingest-foundation:event:document-ingest-skipped": TECHNICAL_EVENT,
24
+ "tenant-lifecycle:event:tenant-destruction-stage-started": TECHNICAL_EVENT,
25
+ "tenant-lifecycle:event:tenant-destruction-stage-succeeded": TECHNICAL_EVENT,
26
+ "tenant-lifecycle:event:tenant-destruction-stage-progressed": TECHNICAL_EVENT,
27
+ "tenant-lifecycle:event:tenant-destruction-stage-failed": TECHNICAL_EVENT,
28
+ "tenant-lifecycle:event:tenant-destruction-stage-abandoned": TECHNICAL_EVENT,
29
+
30
+ "custom-fields:event:custom-field-set": label("custom-field-set"),
31
+ "custom-fields:event:custom-field-cleared": label("custom-field-cleared"),
32
+ "custom-fields:event:field-definition-deleted": label("field-definition-deleted"),
33
+ "secrets:event:read": label("secret-read"),
34
+ "sessions:event:session-revoked": label("session-revoked"),
35
+ "crypto-shredding:event:subject-forgotten": label("subject-forgotten"),
36
+ "crypto-shredding:event:forget-denied": label("forget-denied"),
37
+ "personal-access-tokens:event:pat-revoked": label("pat-revoked"),
38
+ "inbound-mail-foundation:event:mail-account-connected": label("mail-account-connected"),
39
+ "inbound-mail-foundation:event:mail-account-updated": label("mail-account-updated"),
40
+ "inbound-mail-foundation:event:mail-account-disconnected": label("mail-account-disconnected"),
41
+ "billing-foundation:event:subscription-created": label("subscription-created"),
42
+ "billing-foundation:event:subscription-updated": label("subscription-updated"),
43
+ "billing-foundation:event:subscription-canceled": label("subscription-canceled"),
44
+ "billing-foundation:event:invoice-paid": label("invoice-paid"),
45
+ "billing-foundation:event:invoice-payment-failed": label("invoice-payment-failed"),
46
+ "billing-foundation:event:payment-received": label("payment-received"),
47
+ "billing-foundation:event:checkout-consent-recorded": label("checkout-consent-recorded"),
48
+ "billing-foundation:event:contract-confirmation-issued": label("contract-confirmation-issued"),
49
+ "billing-foundation:event:contract-termination-requested": label(
50
+ "contract-termination-requested",
51
+ ),
52
+ "billing-foundation:event:contract-termination-declared": label("contract-termination-declared"),
53
+ "billing-foundation:event:contract-termination-unmatched": label(
54
+ "contract-termination-unmatched",
55
+ ),
56
+ "billing-foundation:event:contract-termination-revoked": label("contract-termination-revoked"),
57
+ "tenant-lifecycle:event:destruction-requested": label("destruction-requested"),
58
+ "tenant-lifecycle:event:destruction-cancelled": label("destruction-cancelled"),
59
+ "tenant-lifecycle:event:tenant-destruction-started": label("tenant-destruction-started"),
60
+ "tenant-lifecycle:event:tenant-destruction-completed": label("tenant-destruction-completed"),
61
+ "tenant-lifecycle:event:tenant-destruction-failed": label("tenant-destruction-failed"),
62
+ "tenant-handover:event:claimed": label("tenant-handover-claimed"),
63
+ "waitlist:event:exported": label("waitlist-exported"),
64
+ "waitlist:event:purged": label("waitlist-purged"),
65
+ "feature-toggles:event:toggle-set": label("feature-toggle-set"),
66
+ };
67
+
68
+ // Framework system events (kumiko:system:*) are engine plumbing, never audit-worthy on their own.
69
+ export const FRAMEWORK_SYSTEM_EVENT_TYPES: readonly string[] = [
70
+ ...WORKFLOW_SYSTEM_EVENT_DEFS.keys(),
71
+ ];
72
+
73
+ export const TECHNICAL_EVENT_TYPES: readonly string[] = [
74
+ ...Object.entries(AUDIT_EVENT_STANCES)
75
+ .filter(([, stance]) => stance === TECHNICAL_EVENT)
76
+ .map(([type]) => type),
77
+ ...FRAMEWORK_SYSTEM_EVENT_TYPES,
78
+ ];
79
+
80
+ export function eventLabelKeyOf(type: string): string | null {
81
+ const stance = AUDIT_EVENT_STANCES[type];
82
+ return stance === undefined || stance === TECHNICAL_EVENT ? null : stance.labelKey;
83
+ }
@@ -17,6 +17,7 @@ import {
17
17
  AUDIT_LOG_COLUMNS,
18
18
  AUDIT_LOG_DATE_RANGE_FACET,
19
19
  AUDIT_LOG_DETAIL_FIELD_LABELS,
20
+ AUDIT_LOG_TECHNICAL_FACET,
20
21
  AUDIT_LOG_TENANT_FACET,
21
22
  auditLogDetailLayout,
22
23
  } from "./screens.ts";
@@ -111,7 +112,7 @@ export function createAuditFeature(): FeatureDefinition {
111
112
  columns: AUDIT_LOG_COLUMNS,
112
113
  searchable: true,
113
114
  defaultSort: { field: "createdAt", dir: "desc" },
114
- facets: [AUDIT_LOG_DATE_RANGE_FACET],
115
+ facets: [AUDIT_LOG_DATE_RANGE_FACET, AUDIT_LOG_TECHNICAL_FACET],
115
116
  rowActions: [
116
117
  {
117
118
  kind: "navigate",
@@ -143,7 +144,7 @@ export function createAuditFeature(): FeatureDefinition {
143
144
  query: AuditQueries.listAllTenants,
144
145
  columns: AUDIT_LOG_ALL_TENANTS_COLUMNS,
145
146
  defaultSort: { field: "createdAt", dir: "desc" },
146
- facets: [AUDIT_LOG_DATE_RANGE_FACET, AUDIT_LOG_TENANT_FACET],
147
+ facets: [AUDIT_LOG_DATE_RANGE_FACET, AUDIT_LOG_TENANT_FACET, AUDIT_LOG_TECHNICAL_FACET],
147
148
  rowActions: [
148
149
  {
149
150
  kind: "navigate",
@@ -6,10 +6,17 @@
6
6
  // REDACTED_PII_PLACEHOLDER before returning (payload and metadata), since the API leak guard refuses ciphertext.
7
7
 
8
8
  import { selectMany, type TenantDb, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
9
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
11
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
11
12
  import * as z from "zod";
12
13
  import { redactPiiCiphertext } from "../../shared/index.ts";
14
+ import { eventLabelKeyOf, TECHNICAL_EVENT_TYPES } from "../event-classification.ts";
15
+ import {
16
+ AUDIT_EVENT_ACTIONS,
17
+ type AuditEventAction,
18
+ INCLUDE_TECHNICAL_FILTER,
19
+ } from "../web/constants.ts";
13
20
 
14
21
  const MAX_LIMIT = 100;
15
22
 
@@ -31,6 +38,21 @@ export const auditListPayloadShape = {
31
38
  to: z.iso.datetime().optional(),
32
39
  } as const;
33
40
 
41
+ // The technical-entries facet rides on the `type` column (facets need a declared column).
42
+ export const technicalEntriesFilterSchema = z.strictObject({
43
+ field: z.literal("type"),
44
+ op: z.literal("in"),
45
+ value: z.array(z.literal(INCLUDE_TECHNICAL_FILTER)).max(1),
46
+ });
47
+
48
+ export function includesTechnicalEntries(
49
+ filters: readonly { readonly field: string; readonly value: readonly unknown[] }[] | undefined,
50
+ ): boolean {
51
+ return (
52
+ filters?.some((f) => f.field === "type" && f.value.includes(INCLUDE_TECHNICAL_FILTER)) ?? false
53
+ );
54
+ }
55
+
34
56
  export const auditEventIdSchema = z
35
57
  .string()
36
58
  .regex(/^[1-9]\d{0,17}$/, "id must be a positive integer");
@@ -69,6 +91,7 @@ export type AuditListFilters = {
69
91
  to?: string | undefined;
70
92
  cursor?: string | undefined;
71
93
  before?: string | undefined;
94
+ includeTechnical?: boolean | undefined;
72
95
  };
73
96
 
74
97
  /** `tenantCondition` is a tenant id, or an operator object for reads across tenants. */
@@ -81,7 +104,12 @@ export function buildAuditWhere(
81
104
  if (p.aggregateType) where["aggregateType"] = p.aggregateType;
82
105
  if (p.aggregateId) where["aggregateId"] = p.aggregateId;
83
106
  if (p.eventType) where["type"] = p.eventType;
84
- else if (p.search) where["type"] = { like: `%${escapeLikePattern(p.search)}%` };
107
+ else if (p.search || !p.includeTechnical) {
108
+ where["type"] = {
109
+ ...(p.search && { like: `%${escapeLikePattern(p.search)}%` }),
110
+ ...(!p.includeTechnical && { notIn: TECHNICAL_EVENT_TYPES }),
111
+ };
112
+ }
85
113
  if (p.userId) where["createdBy"] = p.userId;
86
114
  const range = buildDateRange(p.from, p.to);
87
115
  if (range) where["createdAt"] = range;
@@ -105,7 +133,29 @@ type AuditEventRow = {
105
133
  createdBy: string;
106
134
  };
107
135
 
108
- export function serializeAuditEvent(r: AuditEventRow) {
136
+ function eventActionOf(type: string): AuditEventAction | null {
137
+ const suffix = type.slice(type.lastIndexOf(".") + 1);
138
+ return type.includes(".") ? (AUDIT_EVENT_ACTIONS.find((a) => a === suffix) ?? null) : null;
139
+ }
140
+
141
+ // Entity events carry the entity name as aggregateType; the noun key is the
142
+ // renderer's own `<feature>:entity:<entity>:noun` convention.
143
+ export type EntityNounKeyResolver = (aggregateType: string) => string | null;
144
+
145
+ export function createEntityNounKeyResolver(registry: Registry | undefined): EntityNounKeyResolver {
146
+ const featureByEntity = new Map<string, string>();
147
+ for (const [featureName, feature] of registry?.features ?? []) {
148
+ for (const entityName of Object.keys(feature.entities ?? {})) {
149
+ featureByEntity.set(entityName, featureName);
150
+ }
151
+ }
152
+ return (aggregateType) => {
153
+ const featureName = featureByEntity.get(aggregateType);
154
+ return featureName === undefined ? null : `${featureName}:entity:${aggregateType}:noun`;
155
+ };
156
+ }
157
+
158
+ export function serializeAuditEvent(r: AuditEventRow, nounKeyOf: EntityNounKeyResolver) {
109
159
  return {
110
160
  id: String(r.id),
111
161
  tenantId: r.tenantId,
@@ -113,6 +163,9 @@ export function serializeAuditEvent(r: AuditEventRow) {
113
163
  aggregateType: r.aggregateType,
114
164
  version: r.version,
115
165
  type: r.type,
166
+ eventAction: eventActionOf(r.type),
167
+ eventLabelKey: eventLabelKeyOf(r.type),
168
+ entityNounKey: nounKeyOf(r.aggregateType),
116
169
  payload: redactPiiCiphertext(r.payload),
117
170
  metadata: redactPiiCiphertext(r.metadata),
118
171
  createdAt: r.createdAt,
@@ -120,10 +173,14 @@ export function serializeAuditEvent(r: AuditEventRow) {
120
173
  };
121
174
  }
122
175
 
123
- export async function loadAuditEvent(db: TenantDb, where: WhereObject) {
176
+ export async function loadAuditEvent(
177
+ db: TenantDb,
178
+ where: WhereObject,
179
+ nounKeyOf: EntityNounKeyResolver,
180
+ ) {
124
181
  const rows = await selectMany<AuditEventRow>(db, eventsTable, where, { limit: 1 });
125
182
  const row = rows[0];
126
- return row === undefined ? null : serializeAuditEvent(row);
183
+ return row === undefined ? null : serializeAuditEvent(row, nounKeyOf);
127
184
  }
128
185
 
129
186
  export async function listAuditEvents(
@@ -131,6 +188,7 @@ export async function listAuditEvents(
131
188
  where: WhereObject,
132
189
  p: { limit: number; sort?: "createdAt" | "type" | undefined },
133
190
  direction: "asc" | "desc",
191
+ nounKeyOf: EntityNounKeyResolver,
134
192
  ) {
135
193
  const rows = await selectMany<AuditEventRow>(db, eventsTable, where, {
136
194
  // The id cursor only resumes an id-ordered listing. created_at comes from the
@@ -145,7 +203,7 @@ export async function listAuditEvents(
145
203
  : [{ col: "id", direction }],
146
204
  limit: p.limit,
147
205
  });
148
- const serialised = rows.map(serializeAuditEvent);
206
+ const serialised = rows.map((row) => serializeAuditEvent(row, nounKeyOf));
149
207
  const last = serialised[serialised.length - 1];
150
208
  // An id cursor cannot resume a type-ordered listing, so that sort is one page.
151
209
  const nextId = serialised.length === p.limit && last && p.sort !== "type" ? last.id : null;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
- import { auditEventIdSchema, loadAuditEvent } from "./audit-events.ts";
5
+ import { auditEventIdSchema, createEntityNounKeyResolver, loadAuditEvent } from "./audit-events.ts";
6
6
  import { requireAuditSystemDb } from "./audit-system-db.ts";
7
7
  import { allTenantsCondition, assertMayReadAllTenants } from "./resolve-audit-tenant.ts";
8
8
 
@@ -18,9 +18,10 @@ export const detailsAllTenantsQuery = defineQueryHandler({
18
18
  ctx.systemDb,
19
19
  "audit:query:details-all-tenants",
20
20
  ).acknowledgeCrossTenant("SystemAdmin reads one audit event of any tenant");
21
- return loadAuditEvent(db, {
22
- tenantId: allTenantsCondition(undefined),
23
- id: BigInt(query.payload.id),
24
- });
21
+ return loadAuditEvent(
22
+ db,
23
+ { tenantId: allTenantsCondition(undefined), id: BigInt(query.payload.id) },
24
+ createEntityNounKeyResolver(ctx.registry),
25
+ );
25
26
  },
26
27
  });
@@ -6,7 +6,7 @@
6
6
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import * as z from "zod";
8
8
  import { AUDIT_SCOPE_VALUES } from "../web/constants.ts";
9
- import { auditEventIdSchema, loadAuditEvent } from "./audit-events.ts";
9
+ import { auditEventIdSchema, createEntityNounKeyResolver, loadAuditEvent } from "./audit-events.ts";
10
10
  import { requireAuditSystemDb } from "./audit-system-db.ts";
11
11
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.ts";
12
12
 
@@ -24,10 +24,16 @@ export const detailsQuery = defineQueryHandler({
24
24
  const db = requireAuditSystemDb(ctx.systemDb, "audit:query:details").assertTenantMatch(
25
25
  query.user.tenantId,
26
26
  );
27
- return loadAuditEvent(db, {
28
- tenantId: scopeFilter.tenantId,
29
- ...(scopeFilter.aggregateType !== undefined && { aggregateType: scopeFilter.aggregateType }),
30
- id: BigInt(query.payload.id),
31
- });
27
+ return loadAuditEvent(
28
+ db,
29
+ {
30
+ tenantId: scopeFilter.tenantId,
31
+ ...(scopeFilter.aggregateType !== undefined && {
32
+ aggregateType: scopeFilter.aggregateType,
33
+ }),
34
+ id: BigInt(query.payload.id),
35
+ },
36
+ createEntityNounKeyResolver(ctx.registry),
37
+ );
32
38
  },
33
39
  });
@@ -7,7 +7,10 @@ import {
7
7
  AUDIT_DATE_RANGE_CHECK,
8
8
  auditListPayloadShape,
9
9
  buildAuditWhere,
10
+ createEntityNounKeyResolver,
11
+ includesTechnicalEntries,
10
12
  listAuditEvents,
13
+ technicalEntriesFilterSchema,
11
14
  } from "./audit-events.ts";
12
15
  import { requireAuditSystemDb } from "./audit-system-db.ts";
13
16
  import { allTenantsCondition, assertMayReadAllTenants } from "./resolve-audit-tenant.ts";
@@ -15,33 +18,38 @@ import { allTenantsCondition, assertMayReadAllTenants } from "./resolve-audit-te
15
18
  const MAX_TENANT_FILTER_IDS = 100;
16
19
 
17
20
  // The reference facet on the tenant column sends exactly this shape.
18
- const tenantIdFiltersSchema = z
19
- .array(
20
- z.strictObject({
21
- field: z.literal("tenantId"),
22
- op: z.literal("in"),
23
- value: z.array(z.uuid()).max(MAX_TENANT_FILTER_IDS),
24
- }),
25
- )
26
- .max(1);
21
+ const tenantIdFilterSchema = z.strictObject({
22
+ field: z.literal("tenantId"),
23
+ op: z.literal("in"),
24
+ value: z.array(z.uuid()).max(MAX_TENANT_FILTER_IDS),
25
+ });
26
+
27
+ const auditFiltersSchema = z
28
+ .array(z.union([tenantIdFilterSchema, technicalEntriesFilterSchema]))
29
+ .max(2);
27
30
 
28
31
  export const listAllTenantsQuery = defineQueryHandler({
29
32
  name: "list-all-tenants",
30
33
  description:
31
34
  "Lists audit-trail events of every tenant newest-first with cursor paging, each row carrying its tenantId; filterable by tenant, aggregate type, aggregate id, event type, actor and time range. SystemAdmin only; events of the system tenant are not included.",
32
35
  schema: z
33
- .object({ ...auditListPayloadShape, filters: tenantIdFiltersSchema.optional() })
36
+ .object({ ...auditListPayloadShape, filters: auditFiltersSchema.optional() })
34
37
  .refine(AUDIT_DATE_RANGE_CHECK.isValid, AUDIT_DATE_RANGE_CHECK.params),
35
38
  access: { roles: access.systemAdmin },
36
39
  handler: async (query, ctx) => {
37
40
  assertMayReadAllTenants(query.user);
38
41
  const p = query.payload;
39
42
  const direction = p.sortDirection ?? "desc";
40
- const where = buildAuditWhere(allTenantsCondition(p.filters?.[0]?.value), p, direction);
43
+ const requestedTenantIds = p.filters?.find((f) => f.field === "tenantId")?.value;
44
+ const where = buildAuditWhere(
45
+ allTenantsCondition(requestedTenantIds),
46
+ { ...p, includeTechnical: includesTechnicalEntries(p.filters) },
47
+ direction,
48
+ );
41
49
  const db = requireAuditSystemDb(
42
50
  ctx.systemDb,
43
51
  "audit:query:list-all-tenants",
44
52
  ).acknowledgeCrossTenant("SystemAdmin reads the audit trail of every tenant");
45
- return listAuditEvents(db, where, p, direction);
53
+ return listAuditEvents(db, where, p, direction, createEntityNounKeyResolver(ctx.registry));
46
54
  },
47
55
  });
@@ -16,7 +16,10 @@ import {
16
16
  AUDIT_DATE_RANGE_CHECK,
17
17
  auditListPayloadShape,
18
18
  buildAuditWhere,
19
+ createEntityNounKeyResolver,
20
+ includesTechnicalEntries,
19
21
  listAuditEvents,
22
+ technicalEntriesFilterSchema,
20
23
  } from "./audit-events.ts";
21
24
  import { requireAuditSystemDb } from "./audit-system-db.ts";
22
25
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.ts";
@@ -26,7 +29,11 @@ export const listQuery = defineQueryHandler({
26
29
  description:
27
30
  'Lists the tenant\'s audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when. A SystemAdmin can set scope "system" to read the app-instance system events (e.g. app.started) instead of the own tenant.',
28
31
  schema: z
29
- .object({ ...auditListPayloadShape, scope: z.enum(AUDIT_SCOPE_VALUES).optional() })
32
+ .object({
33
+ ...auditListPayloadShape,
34
+ scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
35
+ filters: z.array(technicalEntriesFilterSchema).max(1).optional(),
36
+ })
30
37
  .refine(AUDIT_DATE_RANGE_CHECK.isValid, AUDIT_DATE_RANGE_CHECK.params),
31
38
  access: { roles: access.admin },
32
39
  handler: async (query, ctx) => {
@@ -35,12 +42,16 @@ export const listQuery = defineQueryHandler({
35
42
  const direction = p.sortDirection ?? "desc";
36
43
  const where = buildAuditWhere(
37
44
  scopeFilter.tenantId,
38
- { ...p, aggregateType: scopeFilter.aggregateType ?? p.aggregateType },
45
+ {
46
+ ...p,
47
+ aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
48
+ includeTechnical: includesTechnicalEntries(p.filters),
49
+ },
39
50
  direction,
40
51
  );
41
52
  const db = requireAuditSystemDb(ctx.systemDb, "audit:query:list").assertTenantMatch(
42
53
  query.user.tenantId,
43
54
  );
44
- return listAuditEvents(db, where, p, direction);
55
+ return listAuditEvents(db, where, p, direction, createEntityNounKeyResolver(ctx.registry));
45
56
  },
46
57
  });
@@ -3,6 +3,11 @@
3
3
 
4
4
  import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { EditLayout, ListColumnSpec } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import {
7
+ AUDIT_EVENT_CELL_COMPONENT,
8
+ AUDIT_OBJECT_CELL_COMPONENT,
9
+ INCLUDE_TECHNICAL_FILTER,
10
+ } from "./web/constants.ts";
6
11
 
7
12
  const TENANT_REFERENCE = { refEntity: "tenant:tenant", refLabelField: "name" } as const;
8
13
  const ACTOR_REFERENCE = { refEntity: "user:user", refLabelField: "displayName" } as const;
@@ -17,18 +22,32 @@ const TENANT_COLUMN = {
17
22
  label: i18nKey("audit.log.col.tenant"),
18
23
  ...TENANT_REFERENCE,
19
24
  } as const satisfies ListColumnSpec;
20
- const TYPE_COLUMN = { field: "type", label: i18nKey("audit.log.col.type") } as const;
25
+ const EVENT_RENDERER = { react: { __component: AUDIT_EVENT_CELL_COMPONENT } } as const;
26
+ const OBJECT_RENDERER = { react: { __component: AUDIT_OBJECT_CELL_COMPONENT } } as const;
27
+
28
+ const TYPE_COLUMN = {
29
+ field: "type",
30
+ label: i18nKey("audit.log.col.type"),
31
+ renderer: EVENT_RENDERER,
32
+ } as const satisfies ListColumnSpec;
33
+ const OBJECT_COLUMN = {
34
+ field: "aggregateType",
35
+ label: i18nKey("audit.log.col.aggregateType"),
36
+ renderer: OBJECT_RENDERER,
37
+ hideOnNarrow: true,
38
+ } as const satisfies ListColumnSpec;
21
39
  const ACTOR_COLUMN = {
22
40
  field: "createdBy",
23
41
  label: i18nKey("audit.log.col.actor"),
24
42
  ...ACTOR_REFERENCE,
25
43
  } as const satisfies ListColumnSpec;
26
44
 
27
- export const AUDIT_LOG_COLUMNS = [WHEN_COLUMN, TYPE_COLUMN, ACTOR_COLUMN] as const;
45
+ export const AUDIT_LOG_COLUMNS = [WHEN_COLUMN, TYPE_COLUMN, OBJECT_COLUMN, ACTOR_COLUMN] as const;
28
46
  export const AUDIT_LOG_ALL_TENANTS_COLUMNS = [
29
47
  WHEN_COLUMN,
30
48
  TENANT_COLUMN,
31
49
  TYPE_COLUMN,
50
+ OBJECT_COLUMN,
32
51
  ACTOR_COLUMN,
33
52
  ] as const;
34
53
 
@@ -39,6 +58,15 @@ export const AUDIT_LOG_DATE_RANGE_FACET = {
39
58
  params: { from: "from", to: "to" },
40
59
  } as const;
41
60
 
61
+ export const AUDIT_LOG_TECHNICAL_FACET = {
62
+ type: "select",
63
+ field: "type",
64
+ label: i18nKey("audit.log.facet.technical"),
65
+ options: [
66
+ { value: INCLUDE_TECHNICAL_FILTER, label: i18nKey("audit.log.facet.technical.include") },
67
+ ],
68
+ } as const;
69
+
42
70
  export const AUDIT_LOG_TENANT_FACET = {
43
71
  type: "reference",
44
72
  field: "tenantId",
@@ -63,10 +91,10 @@ export function auditLogDetailLayout(options: { readonly withTenant: boolean }):
63
91
  sections: [
64
92
  {
65
93
  fields: [
66
- "type",
94
+ { field: "type", renderer: EVENT_RENDERER },
67
95
  { field: "createdAt", renderer: { format: "timestamp" } },
68
96
  ...(options.withTenant ? [{ field: "tenantId", ...TENANT_REFERENCE }] : []),
69
- "aggregateType",
97
+ { field: "aggregateType", renderer: OBJECT_RENDERER },
70
98
  "aggregateId",
71
99
  { field: "createdBy", ...ACTOR_REFERENCE },
72
100
  "id",
@@ -0,0 +1,22 @@
1
+ // Column renderers of the audit log, registered via `auditClient()`'s `columnRenderers`
2
+ // (screens.ts references them by name). The list frame stays the generic projectionList.
3
+
4
+ import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
5
+ import type { ReactNode } from "react";
6
+ import { auditEntityLabel, isAuditEventAction } from "./audit-event-labels.ts";
7
+
8
+ export function AuditEventCell({ value, row }: ColumnRendererProps): ReactNode {
9
+ const t = useTranslation();
10
+ const rawType = typeof value === "string" ? value : "";
11
+ const labelKey = row["eventLabelKey"];
12
+ if (typeof labelKey === "string") return t(labelKey);
13
+ const action = row["eventAction"];
14
+ const entity = auditEntityLabel(t, row);
15
+ if (!isAuditEventAction(action) || entity === null) return rawType;
16
+ return t("audit.log.event", { entity, action: t(`audit.log.action.${action}`) });
17
+ }
18
+
19
+ export function AuditObjectCell({ row }: ColumnRendererProps): ReactNode {
20
+ const t = useTranslation();
21
+ return auditEntityLabel(t, row) ?? "";
22
+ }
@@ -0,0 +1,30 @@
1
+ import type { useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { AUDIT_EVENT_ACTIONS, type AuditEventAction } from "./constants.ts";
3
+
4
+ type Translate = ReturnType<typeof useTranslation>;
5
+
6
+ export function isAuditEventAction(value: unknown): value is AuditEventAction {
7
+ return AUDIT_EVENT_ACTIONS.some((action) => action === value);
8
+ }
9
+
10
+ function humanizeEntityName(aggregateType: string): string {
11
+ const spaced = aggregateType.replaceAll("-", " ").replace(/([a-z])([A-Z])/g, "$1 $2");
12
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
13
+ }
14
+
15
+ /** Singular entity noun: the entity's own `:noun` key, then the audit log's label for bundled entities, then the humanized aggregate type. */
16
+ export function auditEntityLabel(
17
+ t: Translate,
18
+ row: Readonly<Record<string, unknown>>,
19
+ ): string | null {
20
+ const aggregateType = row["aggregateType"];
21
+ if (typeof aggregateType !== "string") return null;
22
+ const nounKey = row["entityNounKey"];
23
+ if (typeof nounKey === "string") {
24
+ const noun = t(nounKey, { count: 1 });
25
+ if (noun !== nounKey) return noun;
26
+ }
27
+ const bundledKey = `audit.log.entity.${aggregateType}`;
28
+ const bundledLabel = t(bundledKey);
29
+ return bundledLabel === bundledKey ? humanizeEntityName(aggregateType) : bundledLabel;
30
+ }
@@ -1,6 +1,11 @@
1
1
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
3
- import { AUDIT_FEATURE } from "./constants.ts";
3
+ import { AuditEventCell, AuditObjectCell } from "./audit-event-cell.tsx";
4
+ import {
5
+ AUDIT_EVENT_CELL_COMPONENT,
6
+ AUDIT_FEATURE,
7
+ AUDIT_OBJECT_CELL_COMPONENT,
8
+ } from "./constants.ts";
4
9
  import { defaultTranslations } from "./default-translations.ts";
5
10
 
6
11
  export type AuditClientOptions = {
@@ -11,5 +16,9 @@ export function auditClient(options?: AuditClientOptions): ClientFeatureDefiniti
11
16
  return {
12
17
  name: AUDIT_FEATURE,
13
18
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
19
+ columnRenderers: {
20
+ [AUDIT_EVENT_CELL_COMPONENT]: AuditEventCell,
21
+ [AUDIT_OBJECT_CELL_COMPONENT]: AuditObjectCell,
22
+ },
14
23
  };
15
24
  }
@@ -13,6 +13,17 @@ export const SYSTEM_ACTOR_IDS: ReadonlySet<string> = new Set([
13
13
  "00000000-0000-0000-0000-000000000000",
14
14
  ]);
15
15
 
16
+ /** Column-renderer names shared by screens.ts and `auditClient()`. */
17
+ export const AUDIT_EVENT_CELL_COMPONENT = "audit-event-cell" as const;
18
+ export const AUDIT_OBJECT_CELL_COMPONENT = "audit-object-cell" as const;
19
+
20
+ /** Action suffix of entity events (`<entity>.<action>`); other event types have none. */
21
+ export const AUDIT_EVENT_ACTIONS = ["created", "updated", "deleted", "restored"] as const;
22
+ export type AuditEventAction = (typeof AUDIT_EVENT_ACTIONS)[number];
23
+
24
+ /** Value of the facet that brings technical entries (escape-hatch use) back into the log. */
25
+ export const INCLUDE_TECHNICAL_FILTER = "include-technical" as const;
26
+
16
27
  export const AuditQueries = {
17
28
  list: "audit:query:list",
18
29
  details: "audit:query:details",