@cosmicdrift/kumiko-bundled-features 0.257.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/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
|
))
|
|
@@ -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(
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
unsafeCreateEntityTable,
|
|
36
36
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
37
37
|
import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
|
|
38
|
-
import { createNoteEntryEntity, noteEntryEntity } from "../entity";
|
|
38
|
+
import { createNoteEntryEntity, noteEntryEntity, noteMentionEntity } from "../entity";
|
|
39
39
|
import { createNotesHistoryFeature } from "../feature";
|
|
40
40
|
|
|
41
41
|
// Minimal fixture standing in for a real host projection: which team a host
|
|
@@ -123,6 +123,7 @@ beforeAll(async () => {
|
|
|
123
123
|
features: [createNotesHistoryFeature({ ownership: teamOwnership }), projectFixtureFeature],
|
|
124
124
|
});
|
|
125
125
|
await unsafeCreateEntityTable(scopedStack.db, createNoteEntryEntity(teamOwnership));
|
|
126
|
+
await unsafeCreateEntityTable(scopedStack.db, noteMentionEntity);
|
|
126
127
|
await unsafeCreateEntityTable(scopedStack.db, projectEntity);
|
|
127
128
|
await createEventsTable(scopedStack.db);
|
|
128
129
|
await asRawClient(scopedStack.db).unsafe(
|
|
@@ -138,6 +139,7 @@ beforeAll(async () => {
|
|
|
138
139
|
features: [createNotesHistoryFeature(), projectFixtureFeature],
|
|
139
140
|
});
|
|
140
141
|
await unsafeCreateEntityTable(defaultStack.db, noteEntryEntity);
|
|
142
|
+
await unsafeCreateEntityTable(defaultStack.db, noteMentionEntity);
|
|
141
143
|
await unsafeCreateEntityTable(defaultStack.db, projectEntity);
|
|
142
144
|
await createEventsTable(defaultStack.db);
|
|
143
145
|
await insertProjects(defaultStack, userA.tenantId, [PROJ_1]);
|
|
@@ -286,6 +288,7 @@ describe("unqualified where-rule fails closed, not open (fw#2639)", () => {
|
|
|
286
288
|
],
|
|
287
289
|
});
|
|
288
290
|
await unsafeCreateEntityTable(unqualifiedStack.db, createNoteEntryEntity(unqualifiedOwnership));
|
|
291
|
+
await unsafeCreateEntityTable(unqualifiedStack.db, noteMentionEntity);
|
|
289
292
|
await unsafeCreateEntityTable(unqualifiedStack.db, projectEntity);
|
|
290
293
|
await createEventsTable(unqualifiedStack.db);
|
|
291
294
|
await asRawClient(unqualifiedStack.db).unsafe(
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
unsafeCreateEntityTable,
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
27
|
import { NotesHistoryHandlers } from "../constants";
|
|
28
|
-
import { createNoteEntryEntity } from "../entity";
|
|
28
|
+
import { createNoteEntryEntity, noteMentionEntity } from "../entity";
|
|
29
29
|
import { createNotesHistoryFeature } from "../feature";
|
|
30
30
|
|
|
31
31
|
const PROJECT_TABLE = "notes_pv_test_projects";
|
|
@@ -118,6 +118,7 @@ beforeAll(async () => {
|
|
|
118
118
|
});
|
|
119
119
|
await unsafeCreateEntityTable(guardedStack.db, guardedProjectEntity);
|
|
120
120
|
await unsafeCreateEntityTable(guardedStack.db, createNoteEntryEntity());
|
|
121
|
+
await unsafeCreateEntityTable(guardedStack.db, noteMentionEntity);
|
|
121
122
|
await createEventsTable(guardedStack.db);
|
|
122
123
|
await asRawClient(guardedStack.db).unsafe(
|
|
123
124
|
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
|
|
@@ -134,6 +135,7 @@ beforeAll(async () => {
|
|
|
134
135
|
});
|
|
135
136
|
await unsafeCreateEntityTable(openStack.db, guardedProjectEntity);
|
|
136
137
|
await unsafeCreateEntityTable(openStack.db, createNoteEntryEntity());
|
|
138
|
+
await unsafeCreateEntityTable(openStack.db, noteMentionEntity);
|
|
137
139
|
await createEventsTable(openStack.db);
|
|
138
140
|
await asRawClient(openStack.db).unsafe(
|
|
139
141
|
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES ($1, $2, 'team-a', 'Project Open')`,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
unsafeCreateEntityTable,
|
|
29
29
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
30
30
|
import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
|
|
31
|
-
import { noteEntryEntity } from "../entity";
|
|
31
|
+
import { noteEntryEntity, noteMentionEntity } from "../entity";
|
|
32
32
|
import { createNotesHistoryFeature } from "../feature";
|
|
33
33
|
|
|
34
34
|
const notesHistoryFeature = createNotesHistoryFeature();
|
|
@@ -73,6 +73,7 @@ const CONTACT_SHARED = "30000000-0000-4000-8000-000000000007";
|
|
|
73
73
|
beforeAll(async () => {
|
|
74
74
|
stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
|
|
75
75
|
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, noteMentionEntity);
|
|
76
77
|
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
77
78
|
await createEventsTable(stack.db);
|
|
78
79
|
|