@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.
- package/package.json +9 -10
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +78 -6
- package/src/auth-mfa/__tests__/screen.test.ts +58 -0
- package/src/auth-mfa/feature.ts +6 -16
- package/src/auth-mfa/handlers/enable-start.write.ts +21 -12
- package/src/auth-mfa/i18n.ts +19 -0
- package/src/auth-mfa/screens.ts +52 -0
- package/src/auth-mfa/web/client-plugin.ts +4 -5
- package/src/auth-mfa/web/i18n.ts +0 -7
- package/src/auth-mfa/web/index.ts +0 -2
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +2 -2
- package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +4 -4
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +5 -5
- package/src/auth-mfa/web/qrcode-browser.d.ts +2 -2
- package/src/folders/__tests__/folders-read-gate.integration.test.ts +179 -0
- package/src/folders/entity.ts +47 -28
- package/src/folders/feature.ts +58 -4
- package/src/folders/handlers/clear-folder.write.ts +4 -4
- package/src/folders/handlers/set-folder.write.ts +4 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +111 -4
- package/src/ledger/entity.ts +30 -0
- package/src/ledger/handlers/confirm-schedule-period.write.ts +7 -0
- package/src/ledger/handlers/create-transaction.write.ts +2 -0
- package/src/ledger/schemas.ts +3 -0
- package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +4 -1
- package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +3 -1
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +243 -0
- package/src/notes-history/entity.ts +11 -1
- package/src/notes-history/feature.ts +12 -11
- package/src/notes-history/handlers/add-note.write.ts +8 -12
- package/src/personal-access-tokens/__tests__/expiry.test.ts +19 -0
- package/src/personal-access-tokens/__tests__/feature.test.ts +93 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +143 -13
- package/src/personal-access-tokens/constants.ts +5 -2
- package/src/personal-access-tokens/expiry.ts +7 -0
- package/src/personal-access-tokens/feature.ts +8 -17
- package/src/personal-access-tokens/handlers/list.query.ts +75 -16
- package/src/personal-access-tokens/i18n.ts +54 -0
- package/src/personal-access-tokens/resolver.ts +2 -7
- package/src/personal-access-tokens/screens.ts +107 -0
- package/src/shared/index.ts +1 -1
- package/src/shared/parent-visibility.ts +30 -0
- package/src/tags/__tests__/tags-read-gate.integration.test.ts +366 -0
- package/src/tags/__tests__/tags.integration.test.ts +1 -0
- package/src/tags/entity.ts +11 -1
- package/src/tags/feature.ts +25 -8
- package/src/tags/handlers/assign-tag.write.ts +2 -8
- package/src/tags/handlers/remove-tag.write.ts +2 -8
- package/src/tags/web/__tests__/entity-tags.test.tsx +13 -4
- package/src/tags/web/entity-tags.tsx +9 -2
- package/src/tags/web/tag-section.tsx +9 -4
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +0 -182
- package/src/auth-mfa/web/mfa-enable-screen.tsx +0 -224
- package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
- package/src/personal-access-tokens/web/i18n.ts +0 -40
- package/src/personal-access-tokens/web/index.ts +0 -7
- 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
|
+
});
|
package/src/folders/entity.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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();
|
package/src/folders/feature.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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) =>
|
|
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 {
|
|
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
|
|
34
|
+
!(await joinRowParentIsVisible(
|
|
35
35
|
ctx.registry,
|
|
36
|
-
|
|
37
|
-
payload
|
|
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 {
|
|
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
|
|
50
|
+
!(await joinRowParentIsVisible(
|
|
51
51
|
ctx.registry,
|
|
52
|
-
|
|
53
|
-
payload
|
|
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: {
|
|
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(
|
|
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: {
|
|
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
|
});
|
package/src/ledger/entity.ts
CHANGED
|
@@ -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,
|
package/src/ledger/schemas.ts
CHANGED
|
@@ -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(
|