@cosmicdrift/kumiko-bundled-features 0.256.0 → 0.258.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 (59) hide show
  1. package/package.json +9 -10
  2. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +78 -6
  3. package/src/auth-mfa/__tests__/screen.test.ts +58 -0
  4. package/src/auth-mfa/feature.ts +6 -16
  5. package/src/auth-mfa/handlers/enable-start.write.ts +21 -12
  6. package/src/auth-mfa/i18n.ts +19 -0
  7. package/src/auth-mfa/screens.ts +52 -0
  8. package/src/auth-mfa/web/client-plugin.ts +4 -5
  9. package/src/auth-mfa/web/i18n.ts +0 -7
  10. package/src/auth-mfa/web/index.ts +0 -2
  11. package/src/auth-mfa/web/mfa-disable-dialog.tsx +2 -2
  12. package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +4 -4
  13. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +5 -5
  14. package/src/auth-mfa/web/qrcode-browser.d.ts +2 -2
  15. package/src/folders/__tests__/folders-read-gate.integration.test.ts +179 -0
  16. package/src/folders/entity.ts +47 -28
  17. package/src/folders/feature.ts +58 -4
  18. package/src/folders/handlers/clear-folder.write.ts +4 -4
  19. package/src/folders/handlers/set-folder.write.ts +4 -4
  20. package/src/ledger/__tests__/ledger.integration.test.ts +111 -4
  21. package/src/ledger/entity.ts +30 -0
  22. package/src/ledger/handlers/confirm-schedule-period.write.ts +7 -0
  23. package/src/ledger/handlers/create-transaction.write.ts +2 -0
  24. package/src/ledger/schemas.ts +3 -0
  25. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +2 -1
  26. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +4 -1
  27. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +3 -1
  28. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  29. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +243 -0
  30. package/src/notes-history/entity.ts +11 -1
  31. package/src/notes-history/feature.ts +12 -11
  32. package/src/notes-history/handlers/add-note.write.ts +8 -12
  33. package/src/personal-access-tokens/__tests__/expiry.test.ts +19 -0
  34. package/src/personal-access-tokens/__tests__/feature.test.ts +93 -0
  35. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +143 -13
  36. package/src/personal-access-tokens/constants.ts +5 -2
  37. package/src/personal-access-tokens/expiry.ts +7 -0
  38. package/src/personal-access-tokens/feature.ts +8 -17
  39. package/src/personal-access-tokens/handlers/list.query.ts +75 -16
  40. package/src/personal-access-tokens/i18n.ts +54 -0
  41. package/src/personal-access-tokens/resolver.ts +2 -7
  42. package/src/personal-access-tokens/screens.ts +107 -0
  43. package/src/shared/index.ts +1 -1
  44. package/src/shared/parent-visibility.ts +30 -0
  45. package/src/tags/__tests__/tags-read-gate.integration.test.ts +366 -0
  46. package/src/tags/__tests__/tags.integration.test.ts +1 -0
  47. package/src/tags/entity.ts +11 -1
  48. package/src/tags/feature.ts +25 -8
  49. package/src/tags/handlers/assign-tag.write.ts +2 -8
  50. package/src/tags/handlers/remove-tag.write.ts +2 -8
  51. package/src/tags/web/__tests__/entity-tags.test.tsx +13 -4
  52. package/src/tags/web/entity-tags.tsx +9 -2
  53. package/src/tags/web/tag-section.tsx +9 -4
  54. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +0 -182
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +0 -224
  56. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  57. package/src/personal-access-tokens/web/i18n.ts +0 -40
  58. package/src/personal-access-tokens/web/index.ts +0 -7
  59. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -296
@@ -0,0 +1,179 @@
1
+ // The read twin of folders-parent-visibility.integration.test.ts (fw#2766).
2
+ // set-folder/clear-folder were gated against the host row's own read path, but
3
+ // `folder-assignment:list` returned every membership row of the tenant — and
4
+ // unlike tags/notes, folders had no `ownership` option at all, so there was no
5
+ // opt-in lever either. The gate below is default-on.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import {
10
+ createEntity,
11
+ createTextField,
12
+ defineFeature,
13
+ type EntityDefinition,
14
+ } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
+ import {
17
+ createTestUser,
18
+ setupTestStack,
19
+ type TestStack,
20
+ unsafeCreateEntityTable,
21
+ } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { FoldersHandlers, FoldersQueries } from "../constants";
23
+ import { folderAssignmentEntity, folderEntity } from "../entity";
24
+ import { createFoldersFeature } from "../feature";
25
+
26
+ const PROJECT_TABLE = "folders_rg_test_projects";
27
+
28
+ const teamScopedRead: NonNullable<EntityDefinition["access"]> = {
29
+ read: {
30
+ TenantMember: {
31
+ kind: "where",
32
+ where: (user, ctx) => ({
33
+ sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
34
+ params: [user.claims?.["team"] ?? null],
35
+ }),
36
+ },
37
+ },
38
+ };
39
+
40
+ const projectEntity = createEntity({
41
+ table: PROJECT_TABLE,
42
+ fields: {
43
+ teamId: createTextField({
44
+ required: true,
45
+ maxLength: 64,
46
+ personal: false,
47
+ reason: "technical_reference",
48
+ }),
49
+ },
50
+ access: teamScopedRead,
51
+ });
52
+
53
+ const hostFixturesFeature = defineFeature("folders-rg-test-fixtures", (r) => {
54
+ r.entity("project", projectEntity);
55
+ });
56
+
57
+ type TestUser = ReturnType<typeof createTestUser>;
58
+
59
+ const userA: TestUser = createTestUser({
60
+ id: 90,
61
+ roles: ["TenantMember"],
62
+ claims: { team: "team-a" },
63
+ });
64
+ const userB: TestUser = createTestUser({
65
+ id: 91,
66
+ roles: ["TenantMember"],
67
+ claims: { team: "team-b" },
68
+ });
69
+
70
+ const PROJECT_A = "e0000000-0000-4000-8000-00000000000a";
71
+ const PROJECT_B = "e0000000-0000-4000-8000-00000000000b";
72
+ const ORPHAN_HOST_ID = "e0000000-0000-4000-8000-0000000000ff";
73
+
74
+ type AssignmentRow = { id: string; folderId: string; entityType: string; entityId: string };
75
+ type Page = { rows: readonly AssignmentRow[]; nextCursor: string | null; total?: number };
76
+
77
+ let stack: TestStack;
78
+ let folderId: string;
79
+
80
+ beforeAll(async () => {
81
+ stack = await setupTestStack({
82
+ features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
83
+ });
84
+ await unsafeCreateEntityTable(stack.db, folderEntity);
85
+ await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
86
+ await unsafeCreateEntityTable(stack.db, projectEntity);
87
+ await createEventsTable(stack.db);
88
+
89
+ await asRawClient(stack.db).unsafe(
90
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id) VALUES ($1, $3, 'team-a'), ($2, $3, 'team-b')`,
91
+ [PROJECT_A, PROJECT_B, userA.tenantId],
92
+ );
93
+
94
+ const folder = await stack.http.writeOk<{ id: string }>(
95
+ FoldersHandlers.createFolder,
96
+ { name: "Shared" },
97
+ userA,
98
+ );
99
+ folderId = folder.id;
100
+
101
+ await stack.http.writeOk(
102
+ FoldersHandlers.setFolder,
103
+ { folderId, entityType: "project", entityId: PROJECT_A },
104
+ userA,
105
+ );
106
+ await stack.http.writeOk(
107
+ FoldersHandlers.setFolder,
108
+ { folderId, entityType: "project", entityId: PROJECT_B },
109
+ userB,
110
+ );
111
+ });
112
+
113
+ afterAll(async () => {
114
+ await stack.cleanup();
115
+ });
116
+
117
+ function listAssignments(payload: Record<string, unknown>, user: TestUser): Promise<Page> {
118
+ return stack.http.queryOk<Page>(FoldersQueries.assignmentList, payload, user);
119
+ }
120
+
121
+ describe("folders read-gate", () => {
122
+ test("the owning team sees which folder its own project sits in", async () => {
123
+ const page = await listAssignments(
124
+ { filter: { field: "entityId", op: "eq", value: PROJECT_A } },
125
+ userA,
126
+ );
127
+ expect(page.rows).toHaveLength(1);
128
+ expect(page.rows[0]?.entityId).toBe(PROJECT_A);
129
+ });
130
+
131
+ test("a foreign team sees nothing there — folders never had an ownership opt-in", async () => {
132
+ const page = await listAssignments(
133
+ { filter: { field: "entityId", op: "eq", value: PROJECT_A }, totalCount: true },
134
+ userB,
135
+ );
136
+ expect(page.rows).toHaveLength(0);
137
+ expect(page.total).toBe(0);
138
+ });
139
+
140
+ test("listing a folder's contents shows only hosts the caller can read", async () => {
141
+ const forA = await listAssignments(
142
+ { filter: { field: "folderId", op: "eq", value: folderId }, totalCount: true },
143
+ userA,
144
+ );
145
+ expect(forA.rows.map((r) => r.entityId)).toEqual([PROJECT_A]);
146
+ expect(forA.total).toBe(1);
147
+
148
+ const forB = await listAssignments(
149
+ { filter: { field: "folderId", op: "eq", value: folderId }, totalCount: true },
150
+ userB,
151
+ );
152
+ expect(forB.rows.map((r) => r.entityId)).toEqual([PROJECT_B]);
153
+ expect(forB.total).toBe(1);
154
+ });
155
+
156
+ test("an assignment whose entityType names no registered entity is invisible to everyone", async () => {
157
+ const orphanId = "e0000000-0000-4000-8000-0000000000f1";
158
+ await asRawClient(stack.db).unsafe(
159
+ `INSERT INTO read_folder_assignments (id, tenant_id, folder_id, entity_type, entity_id, is_deleted)
160
+ VALUES ($1, $2, $3, 'not-a-registered-entity', $4, FALSE)`,
161
+ [orphanId, userA.tenantId, folderId, ORPHAN_HOST_ID],
162
+ );
163
+
164
+ for (const user of [userA, userB]) {
165
+ const page = await listAssignments(
166
+ { filter: { field: "entityId", op: "eq", value: ORPHAN_HOST_ID }, totalCount: true },
167
+ user,
168
+ );
169
+ expect(page.rows).toHaveLength(0);
170
+ expect(page.total).toBe(0);
171
+ }
172
+
173
+ const raw = await asRawClient(stack.db).unsafe<{ n: number }>(
174
+ "SELECT count(*)::int AS n FROM read_folder_assignments WHERE id = $1",
175
+ [orphanId],
176
+ );
177
+ expect(raw[0]?.n).toBe(1);
178
+ });
179
+ });
@@ -1,4 +1,8 @@
1
- import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ createEntity,
3
+ createTextField,
4
+ type EntityDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
 
3
7
  // folder — per-tenant hierarchical catalog. Event-sourced (create/update/delete
4
8
  // via the standard executor); the framework projects `read_folders` from its own
@@ -39,30 +43,45 @@ export const folderEntity = createEntity({
39
43
  // Cross-entity views compose in the read-layer (no JOIN):
40
44
  // - folder of an entity → list assignments filter { field: "entityId", op: "eq" }
41
45
  // - entities in a folder → list assignments filter { field: "folderId", op: "eq" }
42
- export const folderAssignmentEntity = createEntity({
43
- table: "read_folder_assignments",
44
- description:
45
- "The membership row recording which folder one host entity, addressed by entityType and entityId, is filed in. At most one row exists per entity, so filing it elsewhere changes this row's folderId rather than adding a second.",
46
- softDelete: true,
47
- fields: {
48
- folderId: createTextField({
49
- required: true,
50
- maxLength: 64,
51
- personal: false,
52
- reason: "technical_reference",
53
- }),
54
- entityType: createTextField({
55
- required: true,
56
- maxLength: 64,
57
- personal: false,
58
- reason: "technical_reference",
59
- }),
60
- // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
61
- entityId: createTextField({
62
- required: true,
63
- maxLength: 128,
64
- personal: false,
65
- reason: "technical_reference",
66
- }),
67
- },
68
- });
46
+ export function createFolderAssignmentEntity(
47
+ access?: EntityDefinition["access"],
48
+ parents?: readonly string[],
49
+ ) {
50
+ return createEntity({
51
+ table: "read_folder_assignments",
52
+ description:
53
+ "The membership row recording which folder one host entity, addressed by entityType and entityId, is filed in. At most one row exists per entity, so filing it elsewhere changes this row's folderId rather than adding a second.",
54
+ softDelete: true,
55
+ access,
56
+ // Declared unconditionally: the host-visibility gate is the default for
57
+ // both paths, and `parents` only narrows which hosts are admissible.
58
+ parentRef: {
59
+ entityTypeField: "entityType",
60
+ entityIdField: "entityId",
61
+ ...(parents !== undefined && { allowedTypes: parents }),
62
+ },
63
+ fields: {
64
+ folderId: createTextField({
65
+ required: true,
66
+ maxLength: 64,
67
+ personal: false,
68
+ reason: "technical_reference",
69
+ }),
70
+ entityType: createTextField({
71
+ required: true,
72
+ maxLength: 64,
73
+ personal: false,
74
+ reason: "technical_reference",
75
+ }),
76
+ // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
77
+ entityId: createTextField({
78
+ required: true,
79
+ maxLength: 128,
80
+ personal: false,
81
+ reason: "technical_reference",
82
+ }),
83
+ },
84
+ });
85
+ }
86
+
87
+ export const folderAssignmentEntity = createFolderAssignmentEntity();
@@ -22,10 +22,12 @@ import {
22
22
  defineEntityListHandler,
23
23
  defineEntityUpdateHandler,
24
24
  defineFeature,
25
+ type EntityDefinition,
25
26
  type FeatureRegistrar,
26
27
  } from "@cosmicdrift/kumiko-framework/engine";
28
+ import { hasWhereRule } from "../shared";
27
29
  import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants";
28
- import { folderAssignmentEntity, folderEntity } from "./entity";
30
+ import { createFolderAssignmentEntity, folderEntity } from "./entity";
29
31
  import { createClearFolderHandler } from "./handlers/clear-folder.write";
30
32
  import { createDeleteFolderHandler } from "./handlers/delete-folder.write";
31
33
  import { createSetFolderHandler } from "./handlers/set-folder.write";
@@ -41,6 +43,8 @@ function registerFolders(
41
43
  r: FeatureRegistrar<typeof FOLDERS_FEATURE_NAME>,
42
44
  access: AccessRule,
43
45
  toggleable: FoldersToggleable | undefined,
46
+ ownership: EntityDefinition["access"] | undefined,
47
+ parents: readonly string[] | undefined,
44
48
  ): void {
45
49
  r.describe(
46
50
  "Generic, host-agnostic hierarchical folders for any entity. Owns two event-sourced entities — the per-tenant `folder` tree (`read_folders`, self-referential via parentId) and SINGLE-membership `folder-assignment` rows keyed by (entityType, entityId) (`read_folder_assignments`) — so filing an entity adds NO column to the host and needs no relational pivot or JOIN. The folder catalog uses the generic entity handlers (create, update [= rename, optimistic-locked], delete, list, detail); set-folder puts/moves an entity into a folder (one folder per entity) and clear-folder unfiles it (both idempotent). Read which folder an entity is in, or which entities a folder holds, by listing `folder-assignment` filtered on `entityId` or `folderId`. Every path uses one access rule — adopt the host's model with createFoldersFeature({ access: { openToAll: true } }) or pin roles. Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
@@ -53,6 +57,7 @@ function registerFolders(
53
57
 
54
58
  if (toggleable !== undefined) r.toggleable(toggleable);
55
59
 
60
+ const folderAssignmentEntity = createFolderAssignmentEntity(ownership, parents);
56
61
  r.entity("folder", folderEntity);
57
62
  r.entity("folder-assignment", folderAssignmentEntity);
58
63
 
@@ -103,7 +108,7 @@ function registerFolders(
103
108
  }
104
109
 
105
110
  export const foldersFeature = defineFeature(FOLDERS_FEATURE_NAME, (r) =>
106
- registerFolders(r, DEFAULT_FOLDER_ACCESS, undefined),
111
+ registerFolders(r, DEFAULT_FOLDER_ACCESS, undefined, undefined, undefined),
107
112
  );
108
113
 
109
114
  export type FoldersFeatureOptions = {
@@ -119,6 +124,28 @@ export type FoldersFeatureOptions = {
119
124
  * `default` applies when no toggle/tier override exists — use { default: false }
120
125
  * for fail-closed tier-gating. Omit to keep folders always-on (default). */
121
126
  readonly toggleable?: FoldersToggleable;
127
+ /** Row-level ownership on the folder-assignment rows themselves — orthogonal
128
+ * to `access`, which only gates whether a caller may dispatch set/clear/list
129
+ * at all. Not needed to keep a caller from reading assignments on host
130
+ * entities they can't see: that gate is default-on since fw#2766 and derives
131
+ * from the entity's `parentRef`. Use `ownership.read` for an ADDITIONAL row
132
+ * rule on the assignment row itself; it is AND-ed with the host-visibility
133
+ * gate. Applies only to `folder-assignment`; the `folder` catalog stays
134
+ * tenant-wide by design.
135
+ *
136
+ * `ownership.write` is separate and does NOT affect list/read. It's consulted
137
+ * by the framework's generic delete/forget/restore paths. A `from()` rule
138
+ * there also gates GDPR erasure (`forget`): if the rule's role map doesn't
139
+ * cover whatever role the erasure/retention pipeline runs as, `forget` denies
140
+ * instead of crypto-shredding — a silent Art.17 failure, not a thrown error.
141
+ * Make sure any `ownership.write` you set covers that role, or leave it unset. */
142
+ readonly ownership?: EntityDefinition["access"];
143
+ /** Allowlist narrowing which registered entities may be filed into a folder.
144
+ * Not what turns host-checking on — set/clear and the assignment list always
145
+ * verify that entityType names a registered entity whose row the caller can
146
+ * see. Setting it narrows further, and shrinks the read gate's SQL, which
147
+ * otherwise considers every registered entity as a candidate host. */
148
+ readonly parents?: readonly string[];
122
149
  };
123
150
 
124
151
  function resolveAccess(opts: FoldersFeatureOptions): AccessRule {
@@ -130,9 +157,36 @@ function resolveAccess(opts: FoldersFeatureOptions): AccessRule {
130
157
  // Options wrapper. Without options returns the module-level singleton (no
131
158
  // rebuild). access/roles/toggleable build a fresh feature-definition.
132
159
  export function createFoldersFeature(opts: FoldersFeatureOptions = {}): typeof foldersFeature {
133
- if (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
160
+ if (
161
+ opts.access === undefined &&
162
+ opts.roles === undefined &&
163
+ opts.toggleable === undefined &&
164
+ opts.ownership === undefined &&
165
+ opts.parents === undefined
166
+ ) {
134
167
  return foldersFeature;
135
168
  }
169
+ if (hasWhereRule(opts.ownership?.write)) {
170
+ throw new Error(
171
+ "createFoldersFeature({ ownership }): ownership.write must not contain a " +
172
+ '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
173
+ "layer (the read path, via buildOwnershipClause). Write paths that " +
174
+ "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
175
+ "evaluate them, so such a rule can only ever deny — boot validation " +
176
+ "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
177
+ "leave it unset.",
178
+ );
179
+ }
180
+ if (opts.parents !== undefined && opts.parents.length === 0) {
181
+ throw new Error(
182
+ "createFoldersFeature({ parents }): parents must not be an empty array — " +
183
+ "an empty allowlist rejects every set-folder call and hides every " +
184
+ "assignment row. Omit `parents` to keep every registered entity " +
185
+ "admissible as a host instead.",
186
+ );
187
+ }
136
188
  const access = resolveAccess(opts);
137
- return defineFeature(FOLDERS_FEATURE_NAME, (r) => registerFolders(r, access, opts.toggleable));
189
+ return defineFeature(FOLDERS_FEATURE_NAME, (r) =>
190
+ registerFolders(r, access, opts.toggleable, opts.ownership, opts.parents),
191
+ );
138
192
  }
@@ -1,6 +1,6 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
- import { parentRowIsVisible } from "../../shared";
3
+ import { joinRowParentIsVisible } from "../../shared";
4
4
  import { folderAssignmentAggregateId } from "../aggregate-id";
5
5
  import { DEFAULT_FOLDER_ACCESS } from "../constants";
6
6
  import { folderAssignmentExecutor } from "../executor";
@@ -31,10 +31,10 @@ export function createClearFolderHandler(
31
31
  // an invisible parent apart from a missing assignment or an unknown folder —
32
32
  // every path answers with the same NotFoundError.
33
33
  if (
34
- !(await parentRowIsVisible(
34
+ !(await joinRowParentIsVisible(
35
35
  ctx.registry,
36
- payload.entityType,
37
- payload.entityId,
36
+ "folder-assignment",
37
+ payload,
38
38
  event.user,
39
39
  ctx.db,
40
40
  ))
@@ -1,6 +1,6 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
- import { parentRowIsVisible } from "../../shared";
3
+ import { joinRowParentIsVisible } from "../../shared";
4
4
  import { folderAssignmentAggregateId } from "../aggregate-id";
5
5
  import { DEFAULT_FOLDER_ACCESS } from "../constants";
6
6
  import { folderAssignmentExecutor, folderExecutor } from "../executor";
@@ -47,10 +47,10 @@ export function createSetFolderHandler(
47
47
  // an invisible parent apart from a missing assignment or an unknown folder —
48
48
  // every path answers with the same NotFoundError.
49
49
  if (
50
- !(await parentRowIsVisible(
50
+ !(await joinRowParentIsVisible(
51
51
  ctx.registry,
52
- payload.entityType,
53
- payload.entityId,
52
+ "folder-assignment",
53
+ payload,
54
54
  event.user,
55
55
  ctx.db,
56
56
  ))
@@ -64,7 +64,12 @@ async function createAccount(name: string, type: AccountType, user = admin): Pro
64
64
 
65
65
  async function createTransaction(
66
66
  lines: readonly Posting[],
67
- opts: { date?: string; description?: string } = {},
67
+ opts: {
68
+ date?: string;
69
+ description?: string;
70
+ subjectType?: string;
71
+ subjectId?: string;
72
+ } = {},
68
73
  user = admin,
69
74
  ): Promise<{ id: string }> {
70
75
  return stack.http.writeOk<{ id: string }>(
@@ -73,15 +78,20 @@ async function createTransaction(
73
78
  date: opts.date ?? "2026-01-15",
74
79
  description: opts.description ?? "Test entry",
75
80
  lines,
81
+ ...(opts.subjectType !== undefined && { subjectType: opts.subjectType }),
82
+ ...(opts.subjectId !== undefined && { subjectId: opts.subjectId }),
76
83
  },
77
84
  user,
78
85
  );
79
86
  }
80
87
 
81
- async function listTransactions(user = admin): Promise<Array<Record<string, unknown>>> {
88
+ async function listTransactions(
89
+ user = admin,
90
+ query: Record<string, unknown> = {},
91
+ ): Promise<Array<Record<string, unknown>>> {
82
92
  const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
83
93
  LedgerQueries.transactionList,
84
- {},
94
+ query,
85
95
  user,
86
96
  );
87
97
  return res.rows;
@@ -350,7 +360,13 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
350
360
  async function createSchedule(
351
361
  debitAccountId: string,
352
362
  creditAccountId: string,
353
- over: { amount?: number; description?: string; startDate?: string } = {},
363
+ over: {
364
+ amount?: number;
365
+ description?: string;
366
+ startDate?: string;
367
+ subjectType?: string;
368
+ subjectId?: string;
369
+ } = {},
354
370
  ): Promise<string> {
355
371
  const s = await stack.http.writeOk<{ id: string }>(
356
372
  LedgerHandlers.createSchedule,
@@ -361,6 +377,8 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
361
377
  amount: over.amount ?? 50000,
362
378
  debitAccountId,
363
379
  creditAccountId,
380
+ ...(over.subjectType !== undefined && { subjectType: over.subjectType }),
381
+ ...(over.subjectId !== undefined && { subjectId: over.subjectId }),
364
382
  },
365
383
  admin,
366
384
  );
@@ -457,4 +475,93 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
457
475
  // Books net to zero (original cancels Storno) plus the fresh 48000 confirmation.
458
476
  expect(trialBalance(rows)).toBe(0);
459
477
  });
478
+
479
+ test("confirming a period on a schedule with a subject stamps the same subject onto the transaction", async () => {
480
+ const bank = await createAccount("Bank", "asset");
481
+ const rent = await createAccount("Mieterträge", "income");
482
+ const scheduleId = await createSchedule(bank, rent, {
483
+ subjectType: "lease",
484
+ subjectId: "lease-1",
485
+ });
486
+
487
+ await confirm(scheduleId, "2026-01");
488
+
489
+ const rows = await listTransactions();
490
+ expect(rows[0]?.["subjectType"]).toBe("lease");
491
+ expect(rows[0]?.["subjectId"]).toBe("lease-1");
492
+ });
493
+
494
+ test("confirming a period on a schedule without a subject leaves the transaction without one", async () => {
495
+ const bank = await createAccount("Bank", "asset");
496
+ const rent = await createAccount("Mieterträge", "income");
497
+ const scheduleId = await createSchedule(bank, rent);
498
+
499
+ await confirm(scheduleId, "2026-01");
500
+
501
+ const rows = await listTransactions();
502
+ expect(rows[0]?.["subjectType"]).toBeNull();
503
+ expect(rows[0]?.["subjectId"]).toBeNull();
504
+ });
505
+ });
506
+
507
+ describe("ledger integration — subject dimension (filterable business-object reference)", () => {
508
+ test("a booking with subjectType/subjectId round-trips and is findable via an eq filter", async () => {
509
+ const bank = await createAccount("Bank", "asset");
510
+ const rent = await createAccount("Mieterträge", "income");
511
+
512
+ const tx = await createTransaction(
513
+ [
514
+ { accountId: bank, amount: 100000 },
515
+ { accountId: rent, amount: -100000 },
516
+ ],
517
+ { description: "Miete WE1", subjectType: "lease", subjectId: "lease-1" },
518
+ );
519
+ await createTransaction(
520
+ [
521
+ { accountId: bank, amount: 60000 },
522
+ { accountId: rent, amount: -60000 },
523
+ ],
524
+ { description: "Miete WE2", subjectType: "lease", subjectId: "lease-2" },
525
+ );
526
+
527
+ const rows = await listTransactions(admin, {
528
+ filter: { field: "subjectId", op: "eq", value: "lease-1" },
529
+ });
530
+ expect(rows).toHaveLength(1);
531
+ expect(rows[0]?.["id"]).toBe(tx.id);
532
+ expect(rows[0]?.["subjectType"]).toBe("lease");
533
+ expect(rows[0]?.["subjectId"]).toBe("lease-1");
534
+ });
535
+
536
+ test("an in-filter over multiple subjectId values returns exactly the matching bookings", async () => {
537
+ const bank = await createAccount("Bank", "asset");
538
+ const rent = await createAccount("Mieterträge", "income");
539
+
540
+ await createTransaction(
541
+ [
542
+ { accountId: bank, amount: 10000 },
543
+ { accountId: rent, amount: -10000 },
544
+ ],
545
+ { subjectType: "lease", subjectId: "lease-1" },
546
+ );
547
+ await createTransaction(
548
+ [
549
+ { accountId: bank, amount: 20000 },
550
+ { accountId: rent, amount: -20000 },
551
+ ],
552
+ { subjectType: "lease", subjectId: "lease-2" },
553
+ );
554
+ await createTransaction(
555
+ [
556
+ { accountId: bank, amount: 30000 },
557
+ { accountId: rent, amount: -30000 },
558
+ ],
559
+ { subjectType: "lease", subjectId: "lease-3" },
560
+ );
561
+
562
+ const rows = await listTransactions(admin, {
563
+ filters: [{ field: "subjectId", op: "in", value: ["lease-1", "lease-3"] }],
564
+ });
565
+ expect(rows.map((r) => r["subjectId"]).sort()).toEqual(["lease-1", "lease-3"]);
566
+ });
460
567
  });
@@ -78,7 +78,22 @@ export const transactionEntity = createEntity({
78
78
  },
79
79
  { required: true },
80
80
  ),
81
+ // Optional business-object reference (e.g. a lease contract) so entries can
82
+ // be filtered by what they're about, not just grepped out of `reference`.
83
+ subjectType: createTextField({
84
+ maxLength: 64,
85
+ personal: false,
86
+ reason: "technical_reference",
87
+ filterable: true,
88
+ }),
89
+ subjectId: createTextField({
90
+ maxLength: 128,
91
+ personal: false,
92
+ reason: "technical_reference",
93
+ filterable: true,
94
+ }),
81
95
  },
96
+ indexes: [{ columns: ["tenantId", "subjectType", "subjectId"] }],
82
97
  });
83
98
 
84
99
  // schedule — a recurring booking template (Dauerauftrag): "book `amount` from
@@ -116,5 +131,20 @@ export const scheduleEntity = createEntity({
116
131
  personal: false,
117
132
  reason: "technical_reference",
118
133
  }),
134
+ // Confirmed periods inherit this from the schedule (see
135
+ // confirm-schedule-period.write.ts) rather than repeating it per period.
136
+ subjectType: createTextField({
137
+ maxLength: 64,
138
+ personal: false,
139
+ reason: "technical_reference",
140
+ filterable: true,
141
+ }),
142
+ subjectId: createTextField({
143
+ maxLength: 128,
144
+ personal: false,
145
+ reason: "technical_reference",
146
+ filterable: true,
147
+ }),
119
148
  },
149
+ indexes: [{ columns: ["tenantId", "subjectType", "subjectId"] }],
120
150
  });
@@ -96,6 +96,11 @@ export function createConfirmSchedulePeriodHandler(
96
96
  }
97
97
 
98
98
  const amount = payload.amount ?? Number(schedule["amount"]);
99
+ // The subject is a property of the schedule, not the confirm call — a
100
+ // period without one stays without one, no backfill/error.
101
+ const subjectType =
102
+ typeof schedule["subjectType"] === "string" ? schedule["subjectType"] : null;
103
+ const subjectId = typeof schedule["subjectId"] === "string" ? schedule["subjectId"] : null;
99
104
  const created = await transactionExecutor.create(
100
105
  {
101
106
  id: generateId(),
@@ -107,6 +112,8 @@ export function createConfirmSchedulePeriodHandler(
107
112
  { accountId: debitAccountId, amount },
108
113
  { accountId: creditAccountId, amount: -amount },
109
114
  ],
115
+ subjectType,
116
+ subjectId,
110
117
  },
111
118
  event.user,
112
119
  ctx.db,
@@ -35,6 +35,8 @@ export function createCreateTransactionHandler(
35
35
  reference: payload.reference ?? null,
36
36
  status: payload.status ?? "posted",
37
37
  lines: payload.lines,
38
+ subjectType: payload.subjectType ?? null,
39
+ subjectId: payload.subjectId ?? null,
38
40
  },
39
41
  event.user,
40
42
  ctx.db,
@@ -27,6 +27,9 @@ export const createTransactionPayloadSchema = z
27
27
  // the Soll/recurring work — Phase 0 only ever creates posted entries.
28
28
  status: z.enum(TRANSACTION_STATUS).optional(),
29
29
  lines: z.array(postingSchema).min(2),
30
+ // Business-object reference (e.g. a lease contract) this entry is about.
31
+ subjectType: z.string().max(64).optional(),
32
+ subjectId: z.string().max(128).optional(),
30
33
  })
31
34
  .refine((p) => sumIsZero(p.lines), {
32
35
  message: "Transaction must balance: Σ of posting amounts must equal 0",
@@ -34,7 +34,7 @@ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testin
34
34
  import { userEntity, userTable } from "../../user/schema/user";
35
35
  import { seedUser } from "../../user/seeding";
36
36
  import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
37
- import { noteEntryEntity } from "../entity";
37
+ import { noteEntryEntity, noteMentionEntity } from "../entity";
38
38
  import { createNotesHistoryFeature } from "../feature";
39
39
 
40
40
  const notesHistoryFeature = createNotesHistoryFeature();
@@ -66,6 +66,7 @@ function memberUser(userId: string): SessionUser {
66
66
  beforeAll(async () => {
67
67
  stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
68
68
  await unsafeCreateEntityTable(stack.db, noteEntryEntity);
69
+ await unsafeCreateEntityTable(stack.db, noteMentionEntity);
69
70
  await unsafeCreateEntityTable(stack.db, userEntity);
70
71
  await unsafeCreateEntityTable(stack.db, contactEntity);
71
72
  await asRawClient(stack.db).unsafe(