@cosmicdrift/kumiko-samples 0.360.0 → 0.362.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 (48) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/details.query.ts +2 -1
  5. package/packages/bundled-features/src/audit/handlers/list.query.ts +4 -5
  6. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +6 -0
  7. package/packages/bundled-features/src/auth-email-password/web/session.tsx +39 -3
  8. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  9. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  10. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
  11. package/packages/bundled-features/src/document-ingest-foundation/aggregate-id.ts +10 -0
  12. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  13. package/packages/bundled-features/src/document-ingest-foundation/events.ts +3 -1
  14. package/packages/bundled-features/src/document-ingest-foundation/feature.ts +5 -3
  15. package/packages/bundled-features/src/document-ingest-foundation/index.ts +1 -0
  16. package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
  17. package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
  18. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
  19. package/packages/bundled-features/src/shared/index.ts +1 -0
  20. package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
  21. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
  22. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
  23. package/packages/bundled-features/src/user/changes.json +6 -0
  24. package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
  25. package/packages/bundled-features/src/user-data-rights/changes.json +7 -0
  26. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +5 -1
  27. package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts +13 -1
  28. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
  29. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  30. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +6 -12
  31. package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +10 -1
  32. package/packages/bundled-features/src/user-data-rights-defaults/hooks/download-attempt.userdata-hook.ts +58 -0
  33. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
  34. package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
  35. package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
  36. package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
  37. package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
  38. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
  39. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
  40. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
  41. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
  42. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
  43. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
  44. package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
  45. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
  46. package/samples/apps/use-all-bundled/feature-manifest.json +8 -2
  47. package/samples/apps/use-all-bundled/kumiko/migrations/0044_download-attempt-ip-bidx.rebuild.json +4 -0
  48. package/samples/recipes/files-post-processing/src/feature.ts +11 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.360.0",
3
+ "version": "0.362.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.360.0",
3
+ "version": "0.362.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>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "fix",
5
+ "title": "Audit log works under an active subject KMS",
6
+ "detail": "audit list, audit details and user-data-rights my-audit-log returned event payloads as stored. With a subject KMS configured the payload carried kumiko-pii ciphertext and the API leak guard answered 500 pii_ciphertext_leak. Every PII ciphertext string in the payload is now replaced by REDACTED_PII_PLACEHOLDER (\"[[personal data]]\"); the audit log still never shows plaintext PII."
7
+ },
2
8
  {
3
9
  "version": "0.353.0",
4
10
  "type": "fix",
@@ -7,6 +7,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
7
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
9
  import * as z from "zod";
10
+ import { redactPiiCiphertext } from "../../shared/index.js";
10
11
  import { AUDIT_SCOPE_VALUES } from "../constants.js";
11
12
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
12
13
 
@@ -51,7 +52,7 @@ export const detailsQuery = defineQueryHandler({
51
52
  aggregateType: row.aggregateType,
52
53
  version: row.version,
53
54
  type: row.type,
54
- payload: row.payload,
55
+ payload: redactPiiCiphertext(row.payload),
55
56
  metadata: row.metadata,
56
57
  createdAt: row.createdAt,
57
58
  createdBy: row.createdBy,
@@ -9,16 +9,15 @@
9
9
  // A SystemAdmin can pass scope "system" to read the app-instance system events
10
10
  // (e.g. app.started); no other cross-tenant read exists.
11
11
  //
12
- // Sensitive field-values are ciphertext inside the event payload (the log
13
- // carries them encrypted); stripSensitive only strips the event echo. This
14
- // query returns payloads as stored, so a sensitive value surfaces as ciphertext,
15
- // never as plaintext.
12
+ // Event payloads hold PII as subject-KMS ciphertext; it is replaced by
13
+ // REDACTED_PII_PLACEHOLDER before returning, since the API leak guard refuses ciphertext.
16
14
 
17
15
  import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
18
16
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
19
17
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
18
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
21
19
  import * as z from "zod";
20
+ import { redactPiiCiphertext } from "../../shared/index.js";
22
21
  import { AUDIT_SCOPE_VALUES } from "../constants.js";
23
22
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
24
23
 
@@ -132,7 +131,7 @@ export const listQuery = defineQueryHandler({
132
131
  aggregateType: r.aggregateType,
133
132
  version: r.version,
134
133
  type: r.type,
135
- payload: r.payload,
134
+ payload: redactPiiCiphertext(r.payload),
136
135
  metadata: r.metadata,
137
136
  createdAt: r.createdAt,
138
137
  createdBy: r.createdBy,
@@ -452,6 +452,8 @@ export type CurrentUserProfile = {
452
452
  readonly email: string;
453
453
  readonly displayName: string;
454
454
  readonly locale?: string;
455
+ /** Effective IANA zone (user override, else tenant, else UTC). */
456
+ readonly effectiveTimeZone?: string;
455
457
  readonly globalRoles: readonly string[];
456
458
  };
457
459
 
@@ -477,6 +479,7 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
477
479
  email: string;
478
480
  displayName: string;
479
481
  locale?: string;
482
+ effectiveTimeZone?: string;
480
483
  // JSON-encoded string[] — siehe userEntity.roles. Default "[]" wenn
481
484
  // keine globalen Rollen.
482
485
  roles?: string;
@@ -488,6 +491,9 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
488
491
  email: body.data.email,
489
492
  displayName: body.data.displayName,
490
493
  ...(body.data.locale !== undefined && { locale: body.data.locale }),
494
+ ...(body.data.effectiveTimeZone !== undefined && {
495
+ effectiveTimeZone: body.data.effectiveTimeZone,
496
+ }),
491
497
  globalRoles: parseRoles(body.data.roles),
492
498
  };
493
499
  }
@@ -10,8 +10,20 @@
10
10
  // und die Transitions.
11
11
 
12
12
  import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
13
- import { useSessionEndedSignal } from "@cosmicdrift/kumiko-renderer";
14
- import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
13
+ import {
14
+ type CurrentUser,
15
+ CurrentUserProvider,
16
+ useSessionEndedSignal,
17
+ } from "@cosmicdrift/kumiko-renderer";
18
+ import {
19
+ createContext,
20
+ type ReactNode,
21
+ useCallback,
22
+ useContext,
23
+ useEffect,
24
+ useMemo,
25
+ useState,
26
+ } from "react";
15
27
  import {
16
28
  AuthRequestError,
17
29
  type CurrentUserProfile,
@@ -232,7 +244,31 @@ export function SessionProvider({
232
244
  }, []);
233
245
 
234
246
  const api: SessionApi = { ...state, login, logout, switchTenant, refresh: doRefresh };
235
- return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
247
+ // Keyed on the primitives, not the profile object: every refresh (tenant
248
+ // switch, session bootstrap retry) builds a new profile object, and a new
249
+ // CurrentUser reference would re-seed a create form and drop typed input.
250
+ const profile = state.user;
251
+ const userId = profile?.id;
252
+ const userEmail = profile?.email;
253
+ const userDisplayName = profile?.displayName;
254
+ const userTimeZone = profile?.effectiveTimeZone;
255
+ const currentUser = useMemo<CurrentUser | undefined>(
256
+ () =>
257
+ userId === undefined || userEmail === undefined || userDisplayName === undefined
258
+ ? undefined
259
+ : {
260
+ id: userId,
261
+ email: userEmail,
262
+ displayName: userDisplayName,
263
+ ...(userTimeZone !== undefined && { timeZone: userTimeZone }),
264
+ },
265
+ [userId, userEmail, userDisplayName, userTimeZone],
266
+ );
267
+ return (
268
+ <SessionContext.Provider value={api}>
269
+ <CurrentUserProvider user={currentUser}>{children}</CurrentUserProvider>
270
+ </SessionContext.Provider>
271
+ );
236
272
  }
237
273
 
238
274
  export function useSession(): SessionApi {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "cap-counter exports capReservationsTable and capReservationsTableMeta",
6
+ "detail": "The reservation table store_cap_reservations was only reachable through internal imports, so kumiko-enterprise redeclared it in a test. The cap-counter entry now exports capReservationsTable and capReservationsTableMeta, following the feature-toggles pattern."
7
+ },
2
8
  {
3
9
  "version": "0.358.0",
4
10
  "type": "improvement",
@@ -49,6 +49,7 @@ export {
49
49
  type StockCapGuard,
50
50
  type StockCapSpec,
51
51
  } from "./stock-cap-guard.js";
52
+ export { capReservationsTable, capReservationsTableMeta } from "./tables.js";
52
53
  export {
53
54
  createValueCapGuard,
54
55
  type ValueCapGuard,
@@ -55,6 +55,7 @@ import {
55
55
  nullBlindIndexesForSubject,
56
56
  type TenantDb,
57
57
  type WhereObject,
58
+ WritePurposes,
58
59
  } from "@cosmicdrift/kumiko-framework/db";
59
60
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
60
61
  import {
@@ -259,14 +260,14 @@ async function forgetRowAndEraseKeys(args: {
259
260
  type FileFieldPlan = {
260
261
  /** type file|image — stores the fileRef-id directly in the entity column. */
261
262
  readonly singleFields: readonly string[];
262
- /** type files|images — no entity column, resolved via file_refs. */
263
+ /** type files|images — resolved via file_refs (bound by entityType/entityId/fieldName). */
263
264
  readonly multiFields: readonly string[];
264
265
  };
265
266
 
266
267
  const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
267
268
 
268
269
  // file/image = single-column fields (fileRef-id lives directly in the entity
269
- // row); files/images = plural, no column, resolved via file_refs. Same set
270
+ // row); files/images = plural, resolved via file_refs. Same set
270
271
  // as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
271
272
  // isFileField — inlined here rather than importing across the package
272
273
  // barrel since this module already needs the single/multi split, not just
@@ -285,8 +286,8 @@ function planFileFields(fields: Readonly<Record<string, unknown>>): FileFieldPla
285
286
  }
286
287
 
287
288
  // Every fileRef-id a row's file/image/files/images-fields reference: single
288
- // fields hold the id in their own column, plural fields have no column and
289
- // resolve via file_refs (tenantId, entityType, entityId, fieldName).
289
+ // fields hold the id in their own column, plural fields resolve
290
+ // via file_refs (tenantId, entityType, entityId, fieldName).
290
291
  async function collectFileRefIds(
291
292
  db: DbRunner,
292
293
  tenantId: TenantId,
@@ -771,7 +772,8 @@ export async function runRetentionCleanup(
771
772
  proj.table,
772
773
  { ...where, isDeleted: false },
773
774
  batchLimit,
774
- (id) => executor.delete({ id }, systemUser, tdb),
775
+ (id) =>
776
+ executor.delete({ id }, systemUser, tdb, { writePurpose: WritePurposes.retention }),
775
777
  );
776
778
  break;
777
779
  }
@@ -803,7 +805,10 @@ export async function runRetentionCleanup(
803
805
  batchLimit,
804
806
  targets,
805
807
  (id, changes) =>
806
- executor.update({ id, changes }, systemUser, tdb, { skipOptimisticLock: true }),
808
+ executor.update({ id, changes }, systemUser, tdb, {
809
+ skipOptimisticLock: true,
810
+ writePurpose: WritePurposes.retention,
811
+ }),
807
812
  );
808
813
  break;
809
814
  }
@@ -0,0 +1,10 @@
1
+ import { v5 as uuidv5 } from "uuid";
2
+
3
+ // Fixed UUID namespace for document-ingest stream derivation. Frozen: changing
4
+ // it re-keys every existing ingest stream.
5
+ const DOCUMENT_INGEST_NAMESPACE = "5c1e8b7a-3d94-4f2a-b6e0-9a7d1c4f8e23";
6
+
7
+ // @wrapper-known uuid-domain
8
+ export function documentIngestAggregateId(tenantId: string, fileRefId: string): string {
9
+ return uuidv5(`${tenantId}|${fileRefId}`, DOCUMENT_INGEST_NAMESPACE);
10
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.362.0",
4
+ "type": "fix",
5
+ "title": "document-ingest events live on their own stream",
6
+ "detail": "documentIngest.requested/skipped were appended to the fileRef's own event stream (aggregateId = fileRefId), so the request-ingest projection racing a save that binds a pending upload broke the fileRef's optimistic lock (409 version_conflict). They now use documentIngestAggregateId(tenantId, fileRefId); payload.fileRefId is unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,7 +1,9 @@
1
1
  import * as z from "zod";
2
2
 
3
3
  // documentIngest — own aggregate-type for the ingest-request stream, one
4
- // stream per fileRef (aggregateId = fileRefId). Not backed by an r.entity;
4
+ // stream per fileRef with aggregateId derived from the fileRefId
5
+ // (documentIngestAggregateId), never the fileRefId itself: foreign events on
6
+ // the fileRef stream would break the fileRef's optimistic lock. Not backed by an r.entity;
5
7
  // this is a pure saga-anchor event (kumiko-framework#1497) that the Phase-2
6
8
  // worker job (kumiko-enterprise LiteParse provider) subscribes to.
7
9
  export const DOCUMENT_INGEST_AGGREGATE_TYPE = "document-ingest";
@@ -14,6 +14,7 @@ import {
14
14
  } from "@cosmicdrift/kumiko-framework/engine";
15
15
  import { normalizeMimeType } from "@cosmicdrift/kumiko-framework/files";
16
16
  import * as z from "zod";
17
+ import { documentIngestAggregateId } from "./aggregate-id.js";
17
18
  import { validateDocumentIngestProviderWiring } from "./boot-checks.js";
18
19
  import { documentExtractEntity } from "./entity.js";
19
20
  import {
@@ -66,13 +67,14 @@ type FileRefIngestFields = z.infer<typeof fileRefIngestFieldsSchema>;
66
67
  // delete→restore round-trip re-requests ingest instead of leaving the file
67
68
  // without an extract.
68
69
  async function requestIngestForFileRef(
69
- event: { readonly aggregateId: string },
70
+ event: { readonly aggregateId: string; readonly tenantId: string },
70
71
  fileRef: FileRefIngestFields,
71
72
  ctx: MspApplyContext,
72
73
  ): Promise<void> {
74
+ const ingestAggregateId = documentIngestAggregateId(event.tenantId, event.aggregateId);
73
75
  const skip = (reason: "file-too-large" | "unsupported-mime-type") =>
74
76
  ctx.unsafeAppendEvent({
75
- aggregateId: event.aggregateId,
77
+ aggregateId: ingestAggregateId,
76
78
  aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
77
79
  type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
78
80
  payload: {
@@ -105,7 +107,7 @@ async function requestIngestForFileRef(
105
107
  }
106
108
 
107
109
  await ctx.unsafeAppendEvent({
108
- aggregateId: event.aggregateId,
110
+ aggregateId: ingestAggregateId,
109
111
  aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
110
112
  type: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
111
113
  payload: {
@@ -1,5 +1,6 @@
1
1
  // Public API of the document-ingest-foundation bundled-feature.
2
2
 
3
+ export { documentIngestAggregateId } from "./aggregate-id.js";
3
4
  export {
4
5
  type DocumentExtractMeta,
5
6
  documentExtractEntity,
@@ -4,7 +4,7 @@
4
4
  // Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
5
5
  // (a folder name is tenant data, not per-user PII).
6
6
 
7
- import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
+ import { type EventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
9
9
  createEntityExecutor,
10
10
  createSystemUser,
@@ -64,7 +64,9 @@ function tenantScopedDelete(
64
64
  const systemUser = createSystemUser(ctx.tenantId);
65
65
  const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
66
66
  for (const row of rows) {
67
- await executor.delete({ id: row.id }, systemUser, ctx.db);
67
+ await executor.delete({ id: row.id }, systemUser, ctx.db, {
68
+ writePurpose: WritePurposes.erasure,
69
+ });
68
70
  }
69
71
  };
70
72
  }
@@ -3,6 +3,7 @@
3
3
  // consumers without the user-data-rights pipeline don't pull a hard
4
4
  // dependency.
5
5
 
6
+ import { WritePurposes } from "@cosmicdrift/kumiko-framework/db";
6
7
  import {
7
8
  createSystemUser,
8
9
  type UserDataDeleteHook,
@@ -42,7 +43,9 @@ export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
42
43
  const systemUser = createSystemUser(ctx.tenantId);
43
44
  const failures: string[] = [];
44
45
  for (const row of rows) {
45
- const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
46
+ const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db, {
47
+ writePurpose: WritePurposes.erasure,
48
+ });
46
49
  if (!result.isSuccess) {
47
50
  failures.push(`${row.id}: ${result.error.message}`);
48
51
  }
@@ -1,6 +1,6 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import {
3
- collectPiiSubjectFields,
3
+ collectPiiEncryptedFields,
4
4
  configuredPiiSubjectKms,
5
5
  encryptPiiFieldValues,
6
6
  KeyErasedError,
@@ -24,7 +24,7 @@ export async function encryptForDirectWrite(
24
24
  return await encryptPiiFieldValues(
25
25
  row,
26
26
  entity,
27
- collectPiiSubjectFields(entity),
27
+ collectPiiEncryptedFields(entity),
28
28
  kms,
29
29
  { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
30
30
  { entityName },
@@ -33,6 +33,7 @@ export {
33
33
  redactErrorText,
34
34
  redactUrls,
35
35
  } from "./redact.js";
36
+ export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
36
37
  export { requireForTenant } from "./require-for-tenant.js";
37
38
  export {
38
39
  type RowBoundGrantResult,
@@ -0,0 +1,16 @@
1
+ import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
2
+
3
+ export const REDACTED_PII_PLACEHOLDER = "[[personal data]]" as const;
4
+
5
+ // Event payloads store PII fields as subject-KMS ciphertext; the API leak guard
6
+ // rejects any response carrying it, so payload reads swap it for a placeholder.
7
+ export function redactPiiCiphertext(value: unknown): unknown {
8
+ if (isPiiCiphertext(value)) return REDACTED_PII_PLACEHOLDER;
9
+ if (Array.isArray(value)) return value.map(redactPiiCiphertext);
10
+ if (typeof value === "object" && value !== null) {
11
+ return Object.fromEntries(
12
+ Object.entries(value).map(([key, inner]) => [key, redactPiiCiphertext(inner)]),
13
+ );
14
+ }
15
+ return value;
16
+ }
@@ -1,7 +1,11 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbRunner, TenantDb, WhereObject } from "@cosmicdrift/kumiko-framework/db";
4
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ createEventStoreExecutor,
6
+ createTenantDb,
7
+ WritePurposes,
8
+ } from "@cosmicdrift/kumiko-framework/db";
5
9
  import {
6
10
  createSystemUser,
7
11
  type EscapeHatchAuditSink,
@@ -177,7 +181,7 @@ async function markTenantDestroyFailed(db: DbRunner, tenantId: TenantId): Promis
177
181
  },
178
182
  user,
179
183
  scopedDb,
180
- { skipOptimisticLock: true },
184
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
181
185
  );
182
186
  invalidateTenantLifecycleGate(tenantId);
183
187
  }
@@ -436,6 +440,7 @@ export async function runTenantDestructionSweep(args: {
436
440
  { id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } },
437
441
  user,
438
442
  scopedDb,
443
+ { writePurpose: WritePurposes.erasure },
439
444
  );
440
445
  if (!result.isSuccess) continue;
441
446
  invalidateTenantLifecycleGate(tenantId);
@@ -8,6 +8,7 @@ import {
8
8
  createEventStoreExecutor,
9
9
  createTenantDb,
10
10
  type DbRunner,
11
+ WritePurposes,
11
12
  } from "@cosmicdrift/kumiko-framework/db";
12
13
  import {
13
14
  createSystemUser,
@@ -233,7 +234,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<StageRunOut
233
234
  },
234
235
  user,
235
236
  db,
236
- { skipOptimisticLock: true },
237
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
237
238
  );
238
239
  invalidateTenantLifecycleGate(ctx.tenantId);
239
240
  return STAGE_DONE;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "user:me returns effectiveTimeZone, SessionProvider provides the current user",
6
+ "detail": "The me query adds effectiveTimeZone (ctx.tz.user), CurrentUserProfile.effectiveTimeZone is parsed by fetchCurrentUser and SessionProvider wraps its children in CurrentUserProvider."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -8,6 +8,7 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
8
8
 
9
9
  // Returns the currently signed-in user's profile. Field-level read access
10
10
  // strips out the passwordHash automatically (configured on the entity).
11
+ // `effectiveTimeZone` is the effective IANA zone of the caller.
11
12
  export const meQuery = defineQueryHandler({
12
13
  name: "user:me",
13
14
  schema: z.object({}),
@@ -33,6 +34,9 @@ export const meQuery = defineQueryHandler({
33
34
  const db = ctx.systemDb.acknowledgeCrossTenant(
34
35
  "user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter",
35
36
  );
36
- return crud.detail({ id: query.user.id }, query.user, db); // @wrapper-known semantic-alias
37
+ const row = await crud.detail({ id: query.user.id }, query.user, db);
38
+ // `timezone` on the row is only the stored override (often unset); clients
39
+ // need the effective zone (override, else tenant, else UTC) to compute "today".
40
+ return row === null ? row : { ...row, effectiveTimeZone: ctx.tz.user };
37
41
  },
38
42
  });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.362.0",
4
+ "type": "fix",
5
+ "title": "download-attempt.ip and userAgent are encrypted personal data instead of plaintext",
6
+ "detail": "download-attempt.ip and userAgent are declared personal: { of: \"attemptedByUserId\", whenAbsent: \"tenant\" }, ip with find \"exact\" and userAgent with find \"none\". With a KMS configured, rows written by the logged-in job path are encrypted under the requesting user's key (a user forget makes IP and user agent unreadable), rows from the anonymous token path under the tenant key. The new ip_bidx column keeps the equality filter of list-download-attempts working across both kinds of rows, and the query now returns the decrypted values (an erased user's values come back as the erased sentinel). user-data-rights-defaults registers an EXT_USER_DATA hook for download-attempt: export lists the user's own attempts, delete purges the user's own attempts via the forget verb (projection row and blind index included, replayed on rebuild); owner-less token-path rows expire with the 90d hardDelete retention.",
7
+ "migration": "Apps that mount user-data-rights run kumiko-schema generate and apply: the migration adds the ip_bidx column and its index to read_download_attempts. Apps that mount user-data-rights without user-data-rights-defaults must register their own EXT_USER_DATA hook for the download-attempt entity, otherwise boot fails the PII hook coverage check. The annotation fingerprint of the boot PII backfill changes, so the first boot after the bump does one full pass: it encrypts existing download-attempt events (tenant key or user key) and rebuilds the projection including ip_bidx (KUMIKO_SKIP_PII_BACKFILL=1 turns it off)."
8
+ },
2
9
  {
3
10
  "version": "0.354.0",
4
11
  "type": "breaking",
@@ -76,7 +76,11 @@ export const downloadByTokenQuery = defineQueryHandler({
76
76
  })
77
77
  .optional(),
78
78
  }),
79
- access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
79
+ // public-intake: rejected attempts store the anonymous caller's IP in the audit row.
80
+ access: {
81
+ roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"],
82
+ personalData: "public-intake",
83
+ },
80
84
  agent: { expose: false },
81
85
  // Brute-Force-Schutz fuer Token-Hash-Probing. Anonymous-Endpoint mit
82
86
  // 32-byte-Random-Token = 256 Bit Search-Space, aber rate-limit als
@@ -2,12 +2,14 @@ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import * as z from "zod";
5
+ import { decryptStoredPii } from "../../shared/index.js";
5
6
  import { downloadAttemptsTable } from "../schema/download-attempt.js";
6
7
 
7
8
  // Operator-Query: invalid Download-Attempts (S2.U7).
8
9
  // DPO-Sicht fuer Brute-Force-Detection. Tenant-isolated via WHERE.
9
10
 
10
11
  const MAX_LIMIT = 100;
12
+ const REQUEST_ID = "user-data-rights:list-download-attempts";
11
13
 
12
14
  export const listDownloadAttemptsQuery = defineQueryHandler({
13
15
  name: "list-download-attempts",
@@ -44,6 +46,16 @@ export const listDownloadAttemptsQuery = defineQueryHandler({
44
46
  limit: p.limit,
45
47
  });
46
48
 
47
- return { rows };
49
+ const decryptedRows = await Promise.all(
50
+ rows.map(async (row) => ({
51
+ ...row,
52
+ ip: row.ip === null ? null : await decryptStoredPii(row.ip, "ip", REQUEST_ID),
53
+ userAgent:
54
+ row.userAgent === null
55
+ ? null
56
+ : await decryptStoredPii(row.userAgent, "userAgent", REQUEST_ID),
57
+ })),
58
+ );
59
+ return { rows: decryptedRows };
48
60
  },
49
61
  });
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  import * as z from "zod";
6
+ import { redactPiiCiphertext } from "../../shared/index.js";
6
7
 
7
8
  // DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
8
9
  // WHERE createdBy = ctx.user.id ist hard-coded (kein userId-Param,
@@ -75,7 +76,7 @@ export const myAuditLogQuery = defineQueryHandler({
75
76
  aggregateType: r["aggregate_type"],
76
77
  version: r["version"],
77
78
  type: r["type"],
78
- payload: r["payload"],
79
+ payload: redactPiiCiphertext(r["payload"]),
79
80
  createdAt: r["created_at"],
80
81
  }));
81
82
  const last = serialised[serialised.length - 1];
@@ -25,6 +25,7 @@ export type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup.js";
25
25
  export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
26
26
  export type { UserExportBundle } from "./run-user-export.js";
27
27
  export { runUserExport } from "./run-user-export.js";
28
+ export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
28
29
  export {
29
30
  ACTIVE_JOB_CONSTRAINT,
30
31
  EXPORT_JOB_STATUS,
@@ -35,23 +35,17 @@ export const downloadAttemptEntity = createEntity({
35
35
  jobId: createTextField({ personal: false, reason: "technical_reference" }),
36
36
  // User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
37
37
  attemptedByUserId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
38
- // No subject annotation on this buildEntityTable-managed entity:
39
- // attemptedByUserId is null-by-design for the anonymous token path, so
40
- // `personal: { of: "attemptedByUserId" }` would throw SubjectResolutionError
41
- // on every such row once a KMS is configured
42
- // (crypto/subject-resolver.ts resolveUserOwnedSubject).
43
- // The job path knows the requester and an IP is personal data, so this is a
44
- // deliberate waiver, not business data: a user forget does not shred these
45
- // values, the 90d hardDelete below is what removes them.
38
+ // The anonymous token path has no owner, so those rows fall back to the tenant key;
39
+ // find "exact" on ip because list-download-attempts filters by IP.
46
40
  ip: createTextField({
47
41
  maxLength: 64,
48
- personal: false,
49
- reason: "security_audit_retention_bounded",
42
+ personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
43
+ find: "exact",
50
44
  }),
51
45
  userAgent: createTextField({
52
46
  maxLength: 256,
53
- personal: false,
54
- reason: "security_audit_retention_bounded",
47
+ personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
48
+ find: "none",
55
49
  }),
56
50
  attemptedAt: createTimestampField({ required: true }),
57
51
  },
@@ -12,6 +12,10 @@ import {
12
12
  deliveryAttemptDeleteHook,
13
13
  deliveryAttemptExportHook,
14
14
  } from "./hooks/delivery-attempt.userdata-hook.js";
15
+ import {
16
+ downloadAttemptDeleteHook,
17
+ downloadAttemptExportHook,
18
+ } from "./hooks/download-attempt.userdata-hook.js";
15
19
  import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook.js";
16
20
  import {
17
21
  inAppMessageDeleteHook,
@@ -57,7 +61,7 @@ import {
57
61
  export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
58
62
  return defineFeature("user-data-rights-defaults", (r) => {
59
63
  r.describe(
60
- "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding. Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
64
+ "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding, and `download-attempt` (rejected export-download attempts of the user: result, path, source IP, user agent, time; delete purges the user's own attempts via the forget verb, token-path rows without an owner expire by retention). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
61
65
  );
62
66
  r.uiHints({
63
67
  displayLabel: "User Data Rights · Default Hooks",
@@ -128,6 +132,11 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
128
132
  delete: deliveryAttemptDeleteHook,
129
133
  });
130
134
 
135
+ r.useExtension(EXT_USER_DATA, "download-attempt", {
136
+ export: downloadAttemptExportHook,
137
+ delete: downloadAttemptDeleteHook,
138
+ });
139
+
131
140
  r.useExtension(EXT_USER_DATA, "job-run", {
132
141
  export: jobRunExportHook,
133
142
  delete: jobRunDeleteHook,
@@ -0,0 +1,58 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createSystemUser,
4
+ type UserDataDeleteHook,
5
+ type UserDataExportHook,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { collectErasureFailure, throwIfErasureFailed } from "../../shared/index.js";
8
+ import { downloadAttemptEntity, downloadAttemptsTable } from "../../user-data-rights/index.js";
9
+
10
+ // Event-sourced entity: forget goes through the executor so the erasure replays
11
+ // on rebuild and removes the projection row including ip_bidx.
12
+ const crud = createEventStoreExecutor(downloadAttemptsTable, downloadAttemptEntity, {
13
+ entityName: "download-attempt",
14
+ });
15
+
16
+ // Only job-path attempts name a user; token-path rows have no owner and sit
17
+ // under the tenant key. ip may be ciphertext (kumiko-pii:), which the export
18
+ // runner's central decrypt sweep resolves.
19
+ export const downloadAttemptExportHook: UserDataExportHook = async (ctx) => {
20
+ const rows = await ctx.db.selectMany<Record<string, unknown>>(downloadAttemptsTable, {
21
+ tenantId: ctx.tenantId,
22
+ attemptedByUserId: ctx.userId,
23
+ });
24
+ if (rows.length === 0) return null;
25
+ return {
26
+ entity: "download-attempt",
27
+ rows: rows.map((r) => ({
28
+ result: r["result"],
29
+ via: r["via"],
30
+ ip: r["ip"],
31
+ userAgent: r["userAgent"],
32
+ attemptedAt: r["attemptedAt"],
33
+ })),
34
+ };
35
+ };
36
+
37
+ // Both strategies purge: key erasure alone leaves attemptedByUserId and the
38
+ // blind index linking the user to the row until retention, and not every forget
39
+ // path shreds the subject key.
40
+ export const downloadAttemptDeleteHook: UserDataDeleteHook = async (ctx) => {
41
+ const systemUser = createSystemUser(ctx.tenantId);
42
+ const rows = await ctx.db.selectMany<Record<string, unknown>>(downloadAttemptsTable, {
43
+ tenantId: ctx.tenantId,
44
+ attemptedByUserId: ctx.userId,
45
+ });
46
+ const failures: string[] = [];
47
+ for (const row of rows) {
48
+ const id = row["id"];
49
+ if (typeof id !== "string") continue;
50
+ collectErasureFailure(
51
+ await crud.forget({ id }, systemUser, ctx.db),
52
+ "download-attempt",
53
+ id,
54
+ failures,
55
+ );
56
+ }
57
+ throwIfErasureFailed(failures);
58
+ };
@@ -1,4 +1,8 @@
1
- import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ createEventStoreExecutor,
3
+ type TenantDb,
4
+ WritePurposes,
5
+ } from "@cosmicdrift/kumiko-framework/db";
2
6
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
3
7
  import {
4
8
  createSystemUser,
@@ -206,6 +210,7 @@ async function severPersonLink(
206
210
  if (typeof id !== "string") continue;
207
211
  await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
208
212
  skipOptimisticLock: true,
213
+ writePurpose: WritePurposes.erasure,
209
214
  });
210
215
  }
211
216
  }
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createSystemUser,
4
4
  type UserDataDeleteHook,
@@ -117,7 +117,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
117
117
  { id, changes: { email: `forgotten-${id}@anonymized.invalid` } },
118
118
  systemUser,
119
119
  ctx.db,
120
- { skipOptimisticLock: true },
120
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
121
121
  );
122
122
  }
123
123
  }
@@ -138,6 +138,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
138
138
  if (typeof id !== "string") continue;
139
139
  await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, ctx.db, {
140
140
  skipOptimisticLock: true,
141
+ writePurpose: WritePurposes.erasure,
141
142
  });
142
143
  }
143
144
  throwIfErasureFailed(failures);
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createSystemUser,
4
4
  type UserDataDeleteHook,
@@ -102,7 +102,7 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
102
102
  },
103
103
  systemUser,
104
104
  ctx.db,
105
- { skipOptimisticLock: true },
105
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
106
106
  );
107
107
  } else {
108
108
  // anonymize: PII raus, aber Row bleibt active (damit FK-References
@@ -120,7 +120,7 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
120
120
  },
121
121
  systemUser,
122
122
  ctx.db,
123
- { skipOptimisticLock: true },
123
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
124
124
  );
125
125
  }
126
126
  };
@@ -37,14 +37,19 @@ export const shippingEntity = createEntity({
37
37
  });
38
38
 
39
39
  // Profile — testet das Avatar-Image-Upload-Feld (createImageField) in der
40
- // Auto-Form: runde Preview + "Change"-Button, Upload an /api/files.
40
+ // Auto-Form: rechteckige Preview + "Change"-Button, Upload an /api/files.
41
41
  export const profileEntity = createEntity({
42
42
  table: "read_examples_profile",
43
43
  fields: {
44
44
  avatar: createImageField({ maxSize: "5mb", accept: ["jpg", "jpeg", "png"] }),
45
45
  fullName: createTextField({ personal: { of: "id" }, find: "none", required: true }),
46
46
  // Demo-Daten, kein echtes PII-Encryption-Setup → Plaintext bewusst erlaubt.
47
- email: createTextField({ required: true, personal: false, reason: "is_business_data" }),
47
+ email: createTextField({
48
+ required: true,
49
+ personal: false,
50
+ reason: "is_business_data",
51
+ sortable: true,
52
+ }),
48
53
  bio: createTextField({ personal: { of: "id" }, find: "none", multiline: { rows: 3 } }),
49
54
  },
50
55
  });
@@ -105,7 +110,51 @@ export const examplesFeature = defineFeature("examples", (r) => {
105
110
  },
106
111
  });
107
112
 
108
- r.crud("profile", profileEntity, editFormOnly);
113
+ r.crud("profile", profileEntity, {
114
+ ...editFormOnly,
115
+ verbs: { delete: false, restore: false },
116
+ });
117
+
118
+ r.screen({
119
+ id: "profile-list",
120
+ type: "entityList",
121
+ entity: "profile",
122
+ dormant: true,
123
+ columns: ["avatar", "fullName", "email"],
124
+ searchable: true,
125
+ defaultSort: { field: "email", dir: "asc" },
126
+ rowActions: [
127
+ {
128
+ kind: "navigate",
129
+ id: "view",
130
+ label: "examples:profile:view",
131
+ screen: "profile-view",
132
+ entityId: "id",
133
+ rowClick: true,
134
+ },
135
+ ],
136
+ access: { openToAll: { reason: openReason } },
137
+ });
138
+
139
+ r.screen({
140
+ id: "profile-view",
141
+ type: "entityEdit",
142
+ entity: "profile",
143
+ dormant: true,
144
+ listScreenId: "profile-list",
145
+ layout: {
146
+ sections: [
147
+ {
148
+ columns: 2,
149
+ fields: [
150
+ { field: "avatar", span: 2, readOnly: true },
151
+ { field: "fullName", readOnly: true },
152
+ { field: "email", readOnly: true },
153
+ ],
154
+ },
155
+ ],
156
+ },
157
+ });
109
158
 
110
159
  r.screen({
111
160
  id: "profile-edit",
@@ -38,6 +38,9 @@ export const EXAMPLES_I18N: Readonly<Record<string, LocalizedString>> = {
38
38
  de: "Aktualisiere deine persönlichen Daten und wie andere dich sehen.",
39
39
  en: "Update your personal information and how others see you.",
40
40
  },
41
+ "screen:profile-list.title": { de: "Profile", en: "Profiles" },
42
+ "screen:profile-view.title": { de: "Profil ansehen", en: "View profile" },
43
+ "examples:profile:view": { de: "Ansehen", en: "View" },
41
44
  "examples:profile:submit": { de: "Änderungen speichern", en: "Save changes" },
42
45
  "examples:entity:profile:field:avatar": { de: "Avatar", en: "Avatar" },
43
46
  "examples:entity:profile:field:fullName": { de: "Vollständiger Name", en: "Full name" },
@@ -163,6 +163,7 @@ const BOARD_LEASES: readonly BoardLease[] = [
163
163
 
164
164
  const MAX_NACHMIETER_INDEX = 2;
165
165
  const GENERATED_LEASE_COUNT = 300;
166
+ const TERMINATED_LEASE_ID = "00000000-0000-4000-8000-000000003999";
166
167
  const FIRST_NAMES = [
167
168
  "Anna",
168
169
  "Ben",
@@ -204,7 +205,7 @@ function generatedLease(index: number): BoardLease & { readonly terminated: bool
204
205
  einheit: `WE-${String(100 + index)}`,
205
206
  liegenschaft: PROPERTIES[index % PROPERTIES.length] ?? "Haus Eichenweg",
206
207
  beginn: `${year}-${month}-01`,
207
- ...(terminated && { ende: "2025-12-31" }),
208
+ ...(terminated && { ende: "2027-03-31" }),
208
209
  miete: 500 + (index % 20) * 15,
209
210
  terminated,
210
211
  };
@@ -240,7 +241,7 @@ export const seedRental: SeedFn = async (stack) => {
240
241
  }
241
242
  for (let index = 0; index < GENERATED_LEASE_COUNT; index++) {
242
243
  const lease = generatedLease(index);
243
- await createLease(lease, lease.terminated);
244
+ await createLease(lease, lease.terminated, lease.terminated ? TERMINATED_LEASE_ID : undefined);
244
245
  }
245
246
 
246
247
  const maxId = boardLeaseId(MAX_NACHMIETER_INDEX);
@@ -11,6 +11,7 @@ import {
11
11
  germanStatusLabel,
12
12
  idPayloadSchema,
13
13
  leaseExecutor,
14
+ noticePeriodEnd,
14
15
  parseLeaseRow,
15
16
  partyExecutor,
16
17
  positionExecutor,
@@ -22,9 +23,11 @@ import {
22
23
  } from "./lease-support";
23
24
  import {
24
25
  adjustRentScreen,
26
+ leaseDetailGapsScreen,
25
27
  leaseDetailScreen,
26
28
  leaseEditScreen,
27
29
  leaseHubScreen,
30
+ leaseListEmptyScreen,
28
31
  leaseListScreen,
29
32
  leaseListShortScreen,
30
33
  partyEditScreen,
@@ -86,7 +89,8 @@ export const rentalFeature = defineFeature("rental", (r) => {
86
89
  return {
87
90
  id: lease.id,
88
91
  mieter: lease.mieter,
89
- statusLabel: germanStatusLabel(lease.status),
92
+ status: lease.status,
93
+ statusLabel: germanStatusLabel(lease.status, lease.ende),
90
94
  grundmiete: formatGermanMoney(lease.grundmiete),
91
95
  beginn: formatGermanDate(lease.beginn),
92
96
  kuendigungsfrist: lease.kuendigungsfrist ?? "",
@@ -152,7 +156,10 @@ export const rentalFeature = defineFeature("rental", (r) => {
152
156
  leaseExecutor.update(
153
157
  {
154
158
  id: event.payload.id,
155
- changes: { status: "terminated", ende: ctx.tz.today(ctx.tz.tenant).toString() },
159
+ changes: {
160
+ status: "terminated",
161
+ ende: noticePeriodEnd(ctx.tz.today(ctx.tz.tenant).toString()),
162
+ },
156
163
  },
157
164
  event.user,
158
165
  ctx.db,
@@ -232,6 +239,8 @@ export const rentalFeature = defineFeature("rental", (r) => {
232
239
  r.screen(partyEditScreen);
233
240
  r.screen(leaseDetailScreen);
234
241
  r.screen(leaseHubScreen);
242
+ r.screen(leaseDetailGapsScreen);
243
+ r.screen(leaseListEmptyScreen);
235
244
  r.screen(rentalDashboardScreen);
236
245
  r.screen(adjustRentScreen);
237
246
  r.screen(recordPositionsScreen);
@@ -29,15 +29,22 @@ export const rentalTranslations: TranslationsByLocale = {
29
29
  "rental.tab.notes": "Notizen",
30
30
 
31
31
  "rental.action.open": "Öffnen",
32
- "rental.action.terminate": "Mietvertrag kündigen",
32
+ "rental.action.terminate": "Kündigung erfassen",
33
33
  "rental.action.editLease": "Vertragsdaten bearbeiten",
34
- "rental.action.terminateConfirm": "Soll dieser Mietvertrag gekündigt werden?",
34
+ "rental.action.terminateConfirm": "Kündigung zum Ende der Kündigungsfrist erfassen?",
35
35
  "rental.action.addPosition": "Position hinzufügen",
36
36
  "rental.action.adjustRent": "Miete anpassen",
37
37
  "rental.adjustRent.hint":
38
38
  "Die alte Position endet am Vortag, die neue beginnt am Stichtag. Art, Menge und Einheit bleiben dabei unverändert, so bleibt die Mietgeschichte nachvollziehbar.",
39
+ "rental.leaseListEmpty.description":
40
+ "Gekündigte Verträge, die aktuell als aktiv geführt werden.",
41
+ "rental.leaseListEmpty.emptyTitle": "Alles in Ordnung",
42
+ "rental.leaseListEmpty.emptyDescription":
43
+ "Es gibt keine gekündigten Verträge, die noch als aktiv geführt werden.",
44
+ "screen:lease-detail-gaps.title": "Mietvertrag",
45
+ "screen:lease-list-empty.title": "Mietverträge (Prüfliste)",
39
46
  "rental.action.createLease": "Mietvertrag abschließen",
40
- "rental:entity:lease:noun": { one: "Mietvertrag", other: "Mietverträgen" },
47
+ "rental:entity:lease:noun": { one: "Mietvertrag", other: "Mietverträge" },
41
48
  "rental.search.placeholder": "Mieter, Einheit oder Liegenschaft",
42
49
  "rental.positions.description": "Alle Positionen dieses Vertrags mit ihrem Verlauf.",
43
50
  "rental.positions.noun": { one: "Position", other: "Positionen" },
@@ -134,13 +141,18 @@ export const rentalTranslations: TranslationsByLocale = {
134
141
  "rental.tab.notes": "Notes",
135
142
 
136
143
  "rental.action.open": "Open",
137
- "rental.action.terminate": "Terminate lease",
144
+ "rental.action.terminate": "Record notice",
138
145
  "rental.action.editLease": "Edit lease details",
139
- "rental.action.terminateConfirm": "Terminate this lease?",
146
+ "rental.action.terminateConfirm": "Record notice to the end of the notice period?",
140
147
  "rental.action.addPosition": "Add position",
141
148
  "rental.action.adjustRent": "Adjust rent",
142
149
  "rental.adjustRent.hint":
143
150
  "The old position ends the day before, the new one starts on the effective date. Type, quantity and unit stay unchanged, so the rent history stays traceable.",
151
+ "rental.leaseListEmpty.description": "Terminated leases that are still marked active.",
152
+ "rental.leaseListEmpty.emptyTitle": "All clear",
153
+ "rental.leaseListEmpty.emptyDescription": "No terminated leases are still marked active.",
154
+ "screen:lease-detail-gaps.title": "Lease",
155
+ "screen:lease-list-empty.title": "Leases (review list)",
144
156
  "rental.action.createLease": "Sign lease",
145
157
  "rental:entity:lease:noun": { one: "lease", other: "leases" },
146
158
  "rental.search.placeholder": "Tenant, unit or property",
@@ -61,6 +61,7 @@ const leaseRowSchema = z.object({
61
61
  liegenschaft: z.string().nullish(),
62
62
  beginn: readDateSchema,
63
63
  status: z.string(),
64
+ ende: readDateSchema.nullish(),
64
65
  grundmiete: moneySchema,
65
66
  kuendigungsfrist: z.string().nullish(),
66
67
  zahltag: z.number().nullish(),
@@ -90,10 +91,20 @@ const GERMAN_STATUS_LABEL: Readonly<Record<string, string>> = {
90
91
  terminated: "Gekündigt",
91
92
  };
92
93
 
93
- export function germanStatusLabel(status: string): string {
94
+ export function germanStatusLabel(status: string, ende?: string | null): string {
95
+ if (status === "terminated" && ende) return `Gekündigt zum ${formatGermanDate(ende)}`;
94
96
  return GERMAN_STATUS_LABEL[status] ?? status;
95
97
  }
96
98
 
99
+ // Tenant notice (§ 573c BGB, simplified to the 3rd calendar day instead of the
100
+ // 3rd working day): notice received by then ends the lease after the month after
101
+ // next, later notice one month later.
102
+ export function noticePeriodEnd(isoToday: string): string {
103
+ const today = getTemporal().PlainDate.from(isoToday);
104
+ const target = today.add({ months: today.day <= 3 ? 2 : 3 });
105
+ return target.with({ day: target.daysInMonth }).toString();
106
+ }
107
+
97
108
  export function formatGermanDate(isoDate: string): string {
98
109
  const [year, month, day] = isoDate.split("-");
99
110
  return `${day}.${month}.${year}`;
@@ -165,7 +165,8 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
165
165
  { field: "liegenschaft", navigate: { screen: "lease-list" } },
166
166
  ],
167
167
  status: "statusLabel",
168
- statusTones: { Aktiv: "ok", Gekündigt: "bad" },
168
+ statusToneField: "status",
169
+ statusTones: { active: "ok", terminated: "bad" },
169
170
  },
170
171
  metrics: ["grundmiete", "beginn", "kuendigungsfrist", "zahltag", "mieter"],
171
172
  fieldLabels: {
@@ -286,6 +287,7 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
286
287
  handler: RENTAL_WRITES.leaseTerminate,
287
288
  payload: { pick: ["id"] },
288
289
  confirm: "rental.action.terminateConfirm",
290
+ visible: { field: "status", eq: "active" },
289
291
  },
290
292
  {
291
293
  kind: "navigate",
@@ -452,3 +454,46 @@ export const recordPositionsScreen: ActionFormScreenDefinition = {
452
454
  submitLabel: "rental.action.recordPositions",
453
455
  access: OPEN_ACCESS,
454
456
  };
457
+
458
+ // Fixtures for the UI-gap screenshots: a confirm-gated primary action that is
459
+ // visible only while the lease is active (a terminated lease falls back to edit
460
+ // as the main button), and a list that is empty because of its default filter.
461
+ export const leaseDetailGapsScreen: ProjectionDetailScreenDefinition = {
462
+ ...leaseDetailScreen,
463
+ id: "lease-detail-gaps",
464
+ detailFor: "lease",
465
+ primaryAction: "terminate",
466
+ actions: [
467
+ {
468
+ kind: "writeHandler",
469
+ id: "terminate",
470
+ icon: "circle-stop",
471
+ label: "rental.action.terminate",
472
+ handler: RENTAL_WRITES.leaseTerminate,
473
+ payload: { pick: ["id"] },
474
+ confirm: "rental.action.terminateConfirm",
475
+ style: "primary",
476
+ visible: { field: "status", eq: "active" },
477
+ },
478
+ ],
479
+ };
480
+
481
+ export const leaseListEmptyScreen: EntityListScreenDefinition = {
482
+ id: "lease-list-empty",
483
+ type: "entityList",
484
+ entity: "lease",
485
+ dormant: true,
486
+ description: "rental.leaseListEmpty.description",
487
+ emptyState: {
488
+ title: "rental.leaseListEmpty.emptyTitle",
489
+ description: "rental.leaseListEmpty.emptyDescription",
490
+ },
491
+ columns: [...OPEN_FILL_COLUMNS],
492
+ defaultSort: { field: "beginn", dir: "desc" },
493
+ filter: { field: "status", op: "eq", value: "terminated" },
494
+ defaultFilters: { status: ["active"] },
495
+ createLabel: "rental.action.createLease",
496
+ searchPlaceholder: "rental.search.placeholder",
497
+ rowActions: [openLeaseDetail],
498
+ access: OPEN_ACCESS,
499
+ };
@@ -13,6 +13,7 @@ import { vehiclesTranslations } from "./i18n";
13
13
  import {
14
14
  campaignEditScreen,
15
15
  campaignListScreen,
16
+ vehicleEditFlowScreen,
16
17
  vehicleEditScreen,
17
18
  vehicleListScreen,
18
19
  vehicleWizardScreen,
@@ -67,6 +68,7 @@ export const vehiclesFeature = defineFeature("vehicles", (r) => {
67
68
  r.screen(vehicleListScreen);
68
69
  r.screen(vehicleEditScreen);
69
70
  r.screen(vehicleWizardScreen);
71
+ r.screen(vehicleEditFlowScreen);
70
72
  r.screen(campaignListScreen);
71
73
  r.screen(campaignEditScreen);
72
74
 
@@ -10,6 +10,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
10
10
  "screen:vehicle-list.title": "Fahrzeuge",
11
11
  "screen:vehicle-edit.title": "Angaben bearbeiten",
12
12
  "screen:vehicle-wizard.title": "Fahrzeug bearbeiten",
13
+ "screen:vehicle-edit-flow.title": "Ausstattung bearbeiten",
13
14
  "screen:campaign-list.title": "Kampagnen",
14
15
 
15
16
  "vehicles.action.edit": "Bearbeiten",
@@ -17,7 +18,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
17
18
  "vehicles.action.details": "Details",
18
19
  "vehicles.action.saveChanges": "Änderungen speichern",
19
20
  "vehicles.search.placeholder": "Fahrzeug suchen",
20
- "vehicles:entity:vehicle:noun": { one: "Fahrzeug", other: "Fahrzeugen" },
21
+ "vehicles:entity:vehicle:noun": { one: "Fahrzeug", other: "Fahrzeuge" },
21
22
  "vehicles:entity:campaign:noun": { one: "Kampagne", other: "Kampagnen" },
22
23
 
23
24
  "vehicles.section.basics": "Basisdaten",
@@ -119,6 +120,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
119
120
  "screen:vehicle-list.title": "Vehicles",
120
121
  "screen:vehicle-edit.title": "Edit details",
121
122
  "screen:vehicle-wizard.title": "Edit vehicle",
123
+ "screen:vehicle-edit-flow.title": "Edit equipment",
122
124
  "screen:campaign-list.title": "Campaigns",
123
125
 
124
126
  "vehicles.action.edit": "Edit",
@@ -92,6 +92,24 @@ export const vehicleEditScreen: EntityEditScreenDefinition = {
92
92
  access: OPEN_ACCESS,
93
93
  };
94
94
 
95
+ export const vehicleEditFlowScreen: EntityEditScreenDefinition = {
96
+ id: "vehicle-edit-flow",
97
+ type: "entityEdit",
98
+ entity: "vehicle",
99
+ dormant: true,
100
+ listScreenId: "vehicle-list",
101
+ recordTitleField: "modell",
102
+ layout: {
103
+ sections: [
104
+ {
105
+ title: "vehicles.section.equipment",
106
+ fields: ["marke", "modell", { field: "ausstattung", span: 2 }],
107
+ },
108
+ ],
109
+ },
110
+ access: OPEN_ACCESS,
111
+ };
112
+
95
113
  export const vehicleWizardScreen: EntityEditScreenDefinition = {
96
114
  id: "vehicle-wizard",
97
115
  type: "entityEdit",
@@ -1143,7 +1143,9 @@
1143
1143
  "extensionsUsed": [],
1144
1144
  "configKeys": [],
1145
1145
  "secrets": [],
1146
- "writeHandlers": [],
1146
+ "writeHandlers": [
1147
+ "files:write:bind-referenced-files"
1148
+ ],
1147
1149
  "uiHints": {
1148
1150
  "displayLabel": "Files · Metadata",
1149
1151
  "category": "storage",
@@ -3008,7 +3010,7 @@
3008
3010
  },
3009
3011
  {
3010
3012
  "name": "user-data-rights-defaults",
3011
- "description": "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding. Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
3013
+ "description": "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding, and `download-attempt` (rejected export-download attempts of the user: result, path, source IP, user agent, time; delete purges the user's own attempts via the forget verb, token-path rows without an owner expire by retention). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
3012
3014
  "toggleableDefault": null,
3013
3015
  "requires": [
3014
3016
  "user",
@@ -3064,6 +3066,10 @@
3064
3066
  "extensionName": "userData",
3065
3067
  "entityName": "delivery-attempt"
3066
3068
  },
3069
+ {
3070
+ "extensionName": "userData",
3071
+ "entityName": "download-attempt"
3072
+ },
3067
3073
  {
3068
3074
  "extensionName": "userData",
3069
3075
  "entityName": "job-run"
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": 1,
3
+ "tables": ["read_download_attempts"]
4
+ }
@@ -21,6 +21,9 @@ import type { DerivativePublicPredicateArgs } from "@cosmicdrift/kumiko-bundled-
21
21
  import {
22
22
  createEntity,
23
23
  createImageField,
24
+ defineEntityCreateHandler,
25
+ defineEntityDetailHandler,
26
+ defineEntityUpdateHandler,
24
27
  defineFeature,
25
28
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
26
29
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -32,7 +35,7 @@ import { z } from "zod";
32
35
  // the public route's fixed presets (thumb/card/hero/full, entry point 3)
33
36
  // share the name `thumb` but are a completely separate spec — same label,
34
37
  // different whitelist, different actual pixels.
35
- const photoEntity = createEntity({
38
+ export const photoEntity = createEntity({
36
39
  table: "files_post_processing_photos",
37
40
  fields: {
38
41
  original: createImageField({
@@ -59,6 +62,13 @@ export const filesPostProcessingFeature = defineFeature("files-post-processing",
59
62
  r.requires("file-derivatives");
60
63
  r.entity("photo", photoEntity);
61
64
 
65
+ // Uploading a file to a photo's field needs the same rights as saving the
66
+ // photo, so the photo must exist and the uploader needs update access.
67
+ const photoAccess = { access: { roles: ["Admin"] } } as const;
68
+ r.writeHandler(defineEntityCreateHandler("photo", photoEntity, photoAccess));
69
+ r.writeHandler(defineEntityUpdateHandler("photo", photoEntity, photoAccess));
70
+ r.queryHandler(defineEntityDetailHandler("photo", photoEntity, photoAccess));
71
+
62
72
  // Entry point 2 — imperative. A real app would gate this behind real
63
73
  // roles; `openToAll` keeps the recipe's HTTP wiring to one line.
64
74
  r.writeHandler(