@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,366 @@
|
|
|
1
|
+
// The read twin of tags-parent-visibility.integration.test.ts (fw#2766).
|
|
2
|
+
// assign/remove were gated against the host row's own read path since
|
|
3
|
+
// #2721/#2745, but `tag-assignment:list` returned every row of the tenant —
|
|
4
|
+
// including assignments on host rows the caller could never see. Closing that
|
|
5
|
+
// is default-on: none of the mounts below set `ownership`, which used to be
|
|
6
|
+
// the only lever.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
createEntity,
|
|
12
|
+
createTextField,
|
|
13
|
+
defineFeature,
|
|
14
|
+
type EntityDefinition,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
createTestUser,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { TagsHandlers, TagsQueries } from "../constants";
|
|
24
|
+
import { tagAssignmentEntity, tagEntity } from "../entity";
|
|
25
|
+
import { createTagsFeature } from "../feature";
|
|
26
|
+
|
|
27
|
+
const PROJECT_TABLE = "tags_rg_test_projects";
|
|
28
|
+
|
|
29
|
+
const projectOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
30
|
+
read: {
|
|
31
|
+
TenantMember: {
|
|
32
|
+
kind: "where",
|
|
33
|
+
where: (user, ctx) => ({
|
|
34
|
+
sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
|
|
35
|
+
params: [user.claims?.["team"] ?? null],
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const projectEntity = createEntity({
|
|
42
|
+
table: PROJECT_TABLE,
|
|
43
|
+
fields: {
|
|
44
|
+
teamId: createTextField({
|
|
45
|
+
required: true,
|
|
46
|
+
maxLength: 64,
|
|
47
|
+
personal: false,
|
|
48
|
+
reason: "technical_reference",
|
|
49
|
+
}),
|
|
50
|
+
name: createTextField({
|
|
51
|
+
required: true,
|
|
52
|
+
maxLength: 64,
|
|
53
|
+
personal: false,
|
|
54
|
+
reason: "technical_reference",
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
access: projectOwnership,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const hostFixturesFeature = defineFeature("tags-rg-test-fixtures", (r) => {
|
|
61
|
+
r.entity("project", projectEntity);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
type TestUser = ReturnType<typeof createTestUser>;
|
|
65
|
+
|
|
66
|
+
const userA: TestUser = createTestUser({
|
|
67
|
+
id: 70,
|
|
68
|
+
roles: ["TenantMember"],
|
|
69
|
+
claims: { team: "team-a" },
|
|
70
|
+
});
|
|
71
|
+
const userB: TestUser = createTestUser({
|
|
72
|
+
id: 71,
|
|
73
|
+
roles: ["TenantMember"],
|
|
74
|
+
claims: { team: "team-b" },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 4 projects for team-a, 3 for team-b — every one of them carries the same
|
|
78
|
+
// tag, so the tagId scatter query below has a mixed-visibility result set.
|
|
79
|
+
const TEAM_A_PROJECTS = [
|
|
80
|
+
"c0000000-0000-4000-8000-00000000000a",
|
|
81
|
+
"c0000000-0000-4000-8000-00000000000b",
|
|
82
|
+
"c0000000-0000-4000-8000-00000000000c",
|
|
83
|
+
"c0000000-0000-4000-8000-00000000000d",
|
|
84
|
+
];
|
|
85
|
+
const TEAM_B_PROJECTS = [
|
|
86
|
+
"c0000000-0000-4000-8000-00000000001a",
|
|
87
|
+
"c0000000-0000-4000-8000-00000000001b",
|
|
88
|
+
"c0000000-0000-4000-8000-00000000001c",
|
|
89
|
+
];
|
|
90
|
+
// Referenced by an assignment row but registered under no entity name.
|
|
91
|
+
const ORPHAN_HOST_ID = "c0000000-0000-4000-8000-0000000000ff";
|
|
92
|
+
|
|
93
|
+
type AssignmentRow = { id: string; tagId: string; entityType: string; entityId: string };
|
|
94
|
+
type Page = { rows: readonly AssignmentRow[]; nextCursor: string | null; total?: number };
|
|
95
|
+
|
|
96
|
+
let stack: TestStack;
|
|
97
|
+
let tagId: string;
|
|
98
|
+
|
|
99
|
+
beforeAll(async () => {
|
|
100
|
+
stack = await setupTestStack({
|
|
101
|
+
// Deliberately no `ownership` — the gate must hold on a bare mount.
|
|
102
|
+
features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
|
|
103
|
+
});
|
|
104
|
+
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
105
|
+
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
106
|
+
await unsafeCreateEntityTable(stack.db, projectEntity);
|
|
107
|
+
await createEventsTable(stack.db);
|
|
108
|
+
|
|
109
|
+
const values = [...TEAM_A_PROJECTS, ...TEAM_B_PROJECTS]
|
|
110
|
+
.map((id, i) => `('${id}', $1, '${i < TEAM_A_PROJECTS.length ? "team-a" : "team-b"}', 'p${i}')`)
|
|
111
|
+
.join(", ");
|
|
112
|
+
await asRawClient(stack.db).unsafe(
|
|
113
|
+
`INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES ${values}`,
|
|
114
|
+
[userA.tenantId],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const tag = await stack.http.writeOk<{ id: string }>(
|
|
118
|
+
TagsHandlers.createTag,
|
|
119
|
+
{ name: "VIP" },
|
|
120
|
+
userA,
|
|
121
|
+
);
|
|
122
|
+
tagId = tag.id;
|
|
123
|
+
|
|
124
|
+
for (const id of TEAM_A_PROJECTS) {
|
|
125
|
+
await stack.http.writeOk(
|
|
126
|
+
TagsHandlers.assignTag,
|
|
127
|
+
{ tagId, entityType: "project", entityId: id },
|
|
128
|
+
userA,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
for (const id of TEAM_B_PROJECTS) {
|
|
132
|
+
await stack.http.writeOk(
|
|
133
|
+
TagsHandlers.assignTag,
|
|
134
|
+
{ tagId, entityType: "project", entityId: id },
|
|
135
|
+
userB,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(async () => {
|
|
141
|
+
await stack.cleanup();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function listAssignments(payload: Record<string, unknown>, user: TestUser): Promise<Page> {
|
|
145
|
+
return stack.http.queryOk<Page>(TagsQueries.assignmentList, payload, user);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("tags read-gate — entityId-filtered list", () => {
|
|
149
|
+
test("the owning team sees the assignment on its own project", async () => {
|
|
150
|
+
const page = await listAssignments(
|
|
151
|
+
{ filter: { field: "entityId", op: "eq", value: TEAM_A_PROJECTS[0] } },
|
|
152
|
+
userA,
|
|
153
|
+
);
|
|
154
|
+
expect(page.rows).toHaveLength(1);
|
|
155
|
+
expect(page.rows[0]?.entityId).toBe(TEAM_A_PROJECTS[0] as string);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a foreign team sees nothing on that same project, without setting ownership", async () => {
|
|
159
|
+
const page = await listAssignments(
|
|
160
|
+
{ filter: { field: "entityId", op: "eq", value: TEAM_A_PROJECTS[0] } },
|
|
161
|
+
userB,
|
|
162
|
+
);
|
|
163
|
+
expect(page.rows).toHaveLength(0);
|
|
164
|
+
expect(page.nextCursor).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("tags read-gate — unregistered host type", () => {
|
|
169
|
+
test("an assignment whose entityType names no registered entity is invisible to everyone", async () => {
|
|
170
|
+
const orphanId = "c0000000-0000-4000-8000-0000000000f1";
|
|
171
|
+
await asRawClient(stack.db).unsafe(
|
|
172
|
+
`INSERT INTO read_tag_assignments (id, tenant_id, tag_id, entity_type, entity_id, is_deleted)
|
|
173
|
+
VALUES ($1, $2, $3, 'not-a-registered-entity', $4, FALSE)`,
|
|
174
|
+
[orphanId, userA.tenantId, tagId, ORPHAN_HOST_ID],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
for (const user of [userA, userB]) {
|
|
178
|
+
const page = await listAssignments(
|
|
179
|
+
{ filter: { field: "entityId", op: "eq", value: ORPHAN_HOST_ID }, totalCount: true },
|
|
180
|
+
user,
|
|
181
|
+
);
|
|
182
|
+
expect(page.rows).toHaveLength(0);
|
|
183
|
+
expect(page.total).toBe(0);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Positive control: the row really is in the table, so the zero above is
|
|
187
|
+
// the gate and not a failed insert.
|
|
188
|
+
const raw = await asRawClient(stack.db).unsafe<{ n: number }>(
|
|
189
|
+
"SELECT count(*)::int AS n FROM read_tag_assignments WHERE id = $1",
|
|
190
|
+
[orphanId],
|
|
191
|
+
);
|
|
192
|
+
expect(raw[0]?.n).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("tags read-gate — tagId scatter query stays paginated honestly", () => {
|
|
197
|
+
async function drain(user: TestUser, limit: number): Promise<readonly AssignmentRow[]> {
|
|
198
|
+
const seen: AssignmentRow[] = [];
|
|
199
|
+
let cursor: string | null = null;
|
|
200
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
201
|
+
const page: Page = await listAssignments(
|
|
202
|
+
{
|
|
203
|
+
filter: { field: "tagId", op: "eq", value: tagId },
|
|
204
|
+
limit,
|
|
205
|
+
...(cursor !== null && { cursor }),
|
|
206
|
+
},
|
|
207
|
+
user,
|
|
208
|
+
);
|
|
209
|
+
seen.push(...page.rows);
|
|
210
|
+
if (page.nextCursor === null) {
|
|
211
|
+
// A short page must be the last one.
|
|
212
|
+
expect(page.rows.length).toBeLessThanOrEqual(limit);
|
|
213
|
+
return seen;
|
|
214
|
+
}
|
|
215
|
+
// Every non-final page is full — a post-filter would shrink it here.
|
|
216
|
+
expect(page.rows).toHaveLength(limit);
|
|
217
|
+
cursor = page.nextCursor;
|
|
218
|
+
}
|
|
219
|
+
throw new Error("pagination did not terminate");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("total counts only the rows the caller may see", async () => {
|
|
223
|
+
const forB = await listAssignments(
|
|
224
|
+
{ filter: { field: "tagId", op: "eq", value: tagId }, totalCount: true },
|
|
225
|
+
userB,
|
|
226
|
+
);
|
|
227
|
+
expect(forB.total).toBe(TEAM_B_PROJECTS.length);
|
|
228
|
+
|
|
229
|
+
const forA = await listAssignments(
|
|
230
|
+
{ filter: { field: "tagId", op: "eq", value: tagId }, totalCount: true },
|
|
231
|
+
userA,
|
|
232
|
+
);
|
|
233
|
+
expect(forA.total).toBe(TEAM_A_PROJECTS.length);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("paging through the scatter query yields exactly the visible set, once each", async () => {
|
|
237
|
+
const drained = await drain(userA, 2);
|
|
238
|
+
const ids = drained.map((r) => r.entityId).sort();
|
|
239
|
+
expect(ids).toEqual([...TEAM_A_PROJECTS].sort());
|
|
240
|
+
expect(new Set(drained.map((r) => r.id)).size).toBe(drained.length);
|
|
241
|
+
|
|
242
|
+
const { total } = await listAssignments(
|
|
243
|
+
{ filter: { field: "tagId", op: "eq", value: tagId }, totalCount: true },
|
|
244
|
+
userA,
|
|
245
|
+
);
|
|
246
|
+
expect(drained).toHaveLength(total as number);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("every row handed out belongs to a host the caller can actually read", async () => {
|
|
250
|
+
const drained = await drain(userB, 2);
|
|
251
|
+
expect(drained.length).toBeGreaterThan(0);
|
|
252
|
+
for (const row of drained) {
|
|
253
|
+
expect(TEAM_B_PROJECTS).toContain(row.entityId);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("tags read-gate — ownership narrows further, it does not replace the gate", () => {
|
|
259
|
+
const OWNED_TABLE = "tags_rg_owned_projects";
|
|
260
|
+
const ownedEntity = createEntity({
|
|
261
|
+
table: OWNED_TABLE,
|
|
262
|
+
fields: {
|
|
263
|
+
teamId: createTextField({
|
|
264
|
+
required: true,
|
|
265
|
+
maxLength: 64,
|
|
266
|
+
personal: false,
|
|
267
|
+
reason: "technical_reference",
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
270
|
+
access: {
|
|
271
|
+
read: {
|
|
272
|
+
TenantMember: {
|
|
273
|
+
kind: "where",
|
|
274
|
+
where: (user, ctx) => ({
|
|
275
|
+
sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
|
|
276
|
+
params: [user.claims?.["team"] ?? null],
|
|
277
|
+
}),
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
const ownedFixtures = defineFeature("tags-rg-owned-fixtures", (r) => {
|
|
283
|
+
r.entity("project", ownedEntity);
|
|
284
|
+
});
|
|
285
|
+
const HOST = "c0000000-0000-4000-8000-00000000002a";
|
|
286
|
+
// Same team (so the host gate passes for both), but only `tagger` carries
|
|
287
|
+
// the hostKind claim the extra assignment-row rule demands.
|
|
288
|
+
const tagger: TestUser = createTestUser({
|
|
289
|
+
id: 73,
|
|
290
|
+
roles: ["TenantMember"],
|
|
291
|
+
claims: { team: "team-a", hostKind: "project" },
|
|
292
|
+
});
|
|
293
|
+
const peer: TestUser = createTestUser({
|
|
294
|
+
id: 74,
|
|
295
|
+
roles: ["TenantMember"],
|
|
296
|
+
claims: { team: "team-a", hostKind: "something-else" },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
let ownedStack: TestStack;
|
|
300
|
+
let ownedTagId: string;
|
|
301
|
+
|
|
302
|
+
beforeAll(async () => {
|
|
303
|
+
ownedStack = await setupTestStack({
|
|
304
|
+
features: [
|
|
305
|
+
createTagsFeature({
|
|
306
|
+
access: { openToAll: true },
|
|
307
|
+
// An extra row rule on the assignment row itself, unrelated to who
|
|
308
|
+
// may see the host: the caller's claim must name the row's host type.
|
|
309
|
+
ownership: {
|
|
310
|
+
read: {
|
|
311
|
+
TenantMember: {
|
|
312
|
+
kind: "where",
|
|
313
|
+
where: (user, ctx) => ({
|
|
314
|
+
sqlText: `${ctx.tableName}.entity_type = $${ctx.paramStart}`,
|
|
315
|
+
params: [user.claims?.["hostKind"] ?? null],
|
|
316
|
+
}),
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
}),
|
|
321
|
+
ownedFixtures,
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
await unsafeCreateEntityTable(ownedStack.db, tagEntity);
|
|
325
|
+
await unsafeCreateEntityTable(ownedStack.db, tagAssignmentEntity);
|
|
326
|
+
await unsafeCreateEntityTable(ownedStack.db, ownedEntity);
|
|
327
|
+
await createEventsTable(ownedStack.db);
|
|
328
|
+
await asRawClient(ownedStack.db).unsafe(
|
|
329
|
+
`INSERT INTO ${OWNED_TABLE} (id, tenant_id, team_id) VALUES ($1, $2, 'team-a')`,
|
|
330
|
+
[HOST, tagger.tenantId],
|
|
331
|
+
);
|
|
332
|
+
const tag = await ownedStack.http.writeOk<{ id: string }>(
|
|
333
|
+
TagsHandlers.createTag,
|
|
334
|
+
{ name: "Owned" },
|
|
335
|
+
tagger,
|
|
336
|
+
);
|
|
337
|
+
ownedTagId = tag.id;
|
|
338
|
+
await ownedStack.http.writeOk(
|
|
339
|
+
TagsHandlers.assignTag,
|
|
340
|
+
{ tagId: ownedTagId, entityType: "project", entityId: HOST },
|
|
341
|
+
tagger,
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
afterAll(async () => {
|
|
346
|
+
await ownedStack.cleanup();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("a caller passing both the host gate and the extra row rule still sees the row", async () => {
|
|
350
|
+
const page = await ownedStack.http.queryOk<Page>(
|
|
351
|
+
TagsQueries.assignmentList,
|
|
352
|
+
{ filter: { field: "entityId", op: "eq", value: HOST } },
|
|
353
|
+
tagger,
|
|
354
|
+
);
|
|
355
|
+
expect(page.rows).toHaveLength(1);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a same-team peer is cut by ownership even though the host is visible to them", async () => {
|
|
359
|
+
const page = await ownedStack.http.queryOk<Page>(
|
|
360
|
+
TagsQueries.assignmentList,
|
|
361
|
+
{ filter: { field: "entityId", op: "eq", value: HOST } },
|
|
362
|
+
peer,
|
|
363
|
+
);
|
|
364
|
+
expect(page.rows).toHaveLength(0);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -462,6 +462,7 @@ describe("tags integration — openToAll access model", () => {
|
|
|
462
462
|
await unsafeCreateEntityTable(openStack.db, tagEntity);
|
|
463
463
|
await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
|
|
464
464
|
await unsafeCreateEntityTable(openStack.db, creditEntity);
|
|
465
|
+
await unsafeCreateEntityTable(openStack.db, noteEntity);
|
|
465
466
|
await createEventsTable(openStack.db);
|
|
466
467
|
await seedHostRows(openStack, CREDIT_TABLE, ["c-1"], unprivileged.tenantId);
|
|
467
468
|
|
package/src/tags/entity.ts
CHANGED
|
@@ -55,13 +55,23 @@ export const tagEntity = createEntity({
|
|
|
55
55
|
// Cross-entity views compose in the read-layer (no JOIN):
|
|
56
56
|
// - tags of an entity → list assignments filter { field: "entityId", op: "eq" }
|
|
57
57
|
// - entities with a tag → list assignments filter { field: "tagId", op: "eq" }
|
|
58
|
-
export function createTagAssignmentEntity(
|
|
58
|
+
export function createTagAssignmentEntity(
|
|
59
|
+
access?: EntityDefinition["access"],
|
|
60
|
+
parents?: readonly string[],
|
|
61
|
+
) {
|
|
59
62
|
return createEntity({
|
|
60
63
|
table: "read_tag_assignments",
|
|
61
64
|
description:
|
|
62
65
|
"The join row recording that one catalog tag is attached to one host entity, addressed by tagId, entityType and entityId, with exactly one row per pair. An entity may carry many tags, unlike the single-folder membership rows.",
|
|
63
66
|
softDelete: true,
|
|
64
67
|
access,
|
|
68
|
+
// Declared unconditionally: the host-visibility gate is the default for
|
|
69
|
+
// both paths, and `parents` only narrows which hosts are admissible.
|
|
70
|
+
parentRef: {
|
|
71
|
+
entityTypeField: "entityType",
|
|
72
|
+
entityIdField: "entityId",
|
|
73
|
+
...(parents !== undefined && { allowedTypes: parents }),
|
|
74
|
+
},
|
|
65
75
|
fields: {
|
|
66
76
|
tagId: createTextField({
|
|
67
77
|
required: true,
|
package/src/tags/feature.ts
CHANGED
|
@@ -51,6 +51,7 @@ function registerTags(
|
|
|
51
51
|
access: AccessRule,
|
|
52
52
|
toggleable: TagsToggleable | undefined,
|
|
53
53
|
ownership: EntityDefinition["access"] | undefined,
|
|
54
|
+
parents: readonly string[] | undefined,
|
|
54
55
|
): void {
|
|
55
56
|
r.describe(
|
|
56
57
|
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
@@ -65,7 +66,7 @@ function registerTags(
|
|
|
65
66
|
// feature toggleable lets tier-engine/feature-toggles cut it per tenant.
|
|
66
67
|
if (toggleable !== undefined) r.toggleable(toggleable);
|
|
67
68
|
|
|
68
|
-
const tagAssignmentEntity = createTagAssignmentEntity(ownership);
|
|
69
|
+
const tagAssignmentEntity = createTagAssignmentEntity(ownership, parents);
|
|
69
70
|
r.entity("tag", tagEntity);
|
|
70
71
|
r.entity("tag-assignment", tagAssignmentEntity);
|
|
71
72
|
|
|
@@ -128,7 +129,7 @@ function registerTags(
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
131
|
-
registerTags(r, DEFAULT_TAG_ACCESS, undefined, undefined),
|
|
132
|
+
registerTags(r, DEFAULT_TAG_ACCESS, undefined, undefined, undefined),
|
|
132
133
|
);
|
|
133
134
|
|
|
134
135
|
export type TagsFeatureOptions = {
|
|
@@ -146,10 +147,11 @@ export type TagsFeatureOptions = {
|
|
|
146
147
|
readonly toggleable?: TagsToggleable;
|
|
147
148
|
/** Row-level ownership on the tag-assignment rows themselves — orthogonal to
|
|
148
149
|
* `access`, which only gates whether a caller may dispatch assign/remove/list
|
|
149
|
-
* at all.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
150
|
+
* at all. `ownership.read` is no longer what keeps a caller from reading
|
|
151
|
+
* assignments on host entities they can't see: that gate is default-on since
|
|
152
|
+
* fw#2766 and derives from the entity's `parentRef`. Use `ownership.read` for
|
|
153
|
+
* an ADDITIONAL row rule on the assignment row itself; it is AND-ed with the
|
|
154
|
+
* host-visibility gate, never a replacement for it. Applies only to
|
|
153
155
|
* `tag-assignment`; the `tag` catalog stays tenant-wide by design.
|
|
154
156
|
*
|
|
155
157
|
* `ownership.write` is separate and does NOT affect list/read. It's
|
|
@@ -161,6 +163,12 @@ export type TagsFeatureOptions = {
|
|
|
161
163
|
* crypto-shredding — a silent Art.17 failure, not a thrown error. Make
|
|
162
164
|
* sure any `ownership.write` you set covers that role, or leave it unset. */
|
|
163
165
|
readonly ownership?: EntityDefinition["access"];
|
|
166
|
+
/** Allowlist narrowing which registered entities may carry a tag. Not what
|
|
167
|
+
* turns host-checking on — assign/remove and the assignment list always
|
|
168
|
+
* verify that entityType names a registered entity whose row the caller can
|
|
169
|
+
* see. Setting it narrows further, and shrinks the read gate's SQL, which
|
|
170
|
+
* otherwise considers every registered entity as a candidate host. */
|
|
171
|
+
readonly parents?: readonly string[];
|
|
164
172
|
};
|
|
165
173
|
|
|
166
174
|
function resolveAccess(opts: TagsFeatureOptions): AccessRule {
|
|
@@ -177,10 +185,19 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
|
|
|
177
185
|
opts.access === undefined &&
|
|
178
186
|
opts.roles === undefined &&
|
|
179
187
|
opts.toggleable === undefined &&
|
|
180
|
-
opts.ownership === undefined
|
|
188
|
+
opts.ownership === undefined &&
|
|
189
|
+
opts.parents === undefined
|
|
181
190
|
) {
|
|
182
191
|
return tagsFeature;
|
|
183
192
|
}
|
|
193
|
+
if (opts.parents !== undefined && opts.parents.length === 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
"createTagsFeature({ parents }): parents must not be an empty array — " +
|
|
196
|
+
"an empty allowlist rejects every assign-tag call and hides every " +
|
|
197
|
+
"assignment row. Omit `parents` to keep every registered entity " +
|
|
198
|
+
"admissible as a host instead.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
184
201
|
if (hasWhereRule(opts.ownership?.write)) {
|
|
185
202
|
throw new Error(
|
|
186
203
|
"createTagsFeature({ ownership }): ownership.write must not contain a " +
|
|
@@ -194,6 +211,6 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
|
|
|
194
211
|
}
|
|
195
212
|
const access = resolveAccess(opts);
|
|
196
213
|
return defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
197
|
-
registerTags(r, access, opts.toggleable, opts.ownership),
|
|
214
|
+
registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
|
|
198
215
|
);
|
|
199
216
|
}
|
|
@@ -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 { tagAssignmentAggregateId } from "../aggregate-id";
|
|
5
5
|
import { DEFAULT_TAG_ACCESS } from "../constants";
|
|
6
6
|
import { tagAssignmentExecutor, tagExecutor } from "../executor";
|
|
@@ -43,13 +43,7 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
43
43
|
// an invisible parent apart from a missing assignment or an unknown tag —
|
|
44
44
|
// every path answers with the same NotFoundError.
|
|
45
45
|
if (
|
|
46
|
-
!(await
|
|
47
|
-
ctx.registry,
|
|
48
|
-
payload.entityType,
|
|
49
|
-
payload.entityId,
|
|
50
|
-
event.user,
|
|
51
|
-
ctx.db,
|
|
52
|
-
))
|
|
46
|
+
!(await joinRowParentIsVisible(ctx.registry, "tag-assignment", payload, event.user, ctx.db))
|
|
53
47
|
) {
|
|
54
48
|
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
55
49
|
}
|
|
@@ -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 { tagAssignmentAggregateId } from "../aggregate-id";
|
|
5
5
|
import { DEFAULT_TAG_ACCESS } from "../constants";
|
|
6
6
|
import { tagAssignmentExecutor } from "../executor";
|
|
@@ -29,13 +29,7 @@ export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
29
29
|
// an invisible parent apart from a missing assignment or an unknown tag —
|
|
30
30
|
// every path answers with the same NotFoundError.
|
|
31
31
|
if (
|
|
32
|
-
!(await
|
|
33
|
-
ctx.registry,
|
|
34
|
-
payload.entityType,
|
|
35
|
-
payload.entityId,
|
|
36
|
-
event.user,
|
|
37
|
-
ctx.db,
|
|
38
|
-
))
|
|
32
|
+
!(await joinRowParentIsVisible(ctx.registry, "tag-assignment", payload, event.user, ctx.db))
|
|
39
33
|
) {
|
|
40
34
|
return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
|
|
41
35
|
}
|
|
@@ -22,7 +22,7 @@ beforeEach(() => {
|
|
|
22
22
|
assignmentRows = [];
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
const useQuerySpy = mock((type: string) => ({
|
|
25
|
+
const useQuerySpy = mock((type: string, _payload?: unknown) => ({
|
|
26
26
|
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
27
27
|
loading: false,
|
|
28
28
|
error: null,
|
|
@@ -79,9 +79,10 @@ describe("EntityTags", () => {
|
|
|
79
79
|
expect(screen.queryByTestId("tag-chip")).toBeNull();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
test("
|
|
82
|
+
test("scopes the assignment query to this entityType and id server-side", () => {
|
|
83
83
|
catalogRows = [{ id: "t1", name: "important" }];
|
|
84
|
-
assignmentRows = [{ tagId: "t1", entityType: "
|
|
84
|
+
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
|
|
85
|
+
useQuerySpy.mockClear();
|
|
85
86
|
|
|
86
87
|
render(
|
|
87
88
|
<Wrapper>
|
|
@@ -89,7 +90,15 @@ describe("EntityTags", () => {
|
|
|
89
90
|
</Wrapper>,
|
|
90
91
|
);
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
// Filtering server-side (rather than discarding foreign rows after the
|
|
94
|
+
// fetch) is what lets the parent-ref read gate narrow to one host entity.
|
|
95
|
+
const call = useQuerySpy.mock.calls.find((c) => c[0] === TagsQueries.assignmentList);
|
|
96
|
+
expect(call?.[1]).toEqual({
|
|
97
|
+
filters: [
|
|
98
|
+
{ field: "entityType", op: "eq", value: "note" },
|
|
99
|
+
{ field: "entityId", op: "eq", value: "n1" },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
93
102
|
});
|
|
94
103
|
|
|
95
104
|
test("renders nothing for a not-yet-saved entity (entityId null)", () => {
|
|
@@ -25,15 +25,22 @@ export function EntityTags({
|
|
|
25
25
|
}): ReactNode {
|
|
26
26
|
const enabled = entityId !== null;
|
|
27
27
|
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {}, { enabled });
|
|
28
|
+
// entityType server-side, not just entityId: it collapses the parent-ref
|
|
29
|
+
// read gate to this one host entity instead of every registered one.
|
|
28
30
|
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(
|
|
29
31
|
TagsQueries.assignmentList,
|
|
30
|
-
{
|
|
32
|
+
{
|
|
33
|
+
filters: [
|
|
34
|
+
{ field: "entityType", op: "eq", value: entityName },
|
|
35
|
+
{ field: "entityId", op: "eq", value: entityId },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
31
38
|
{ enabled },
|
|
32
39
|
);
|
|
33
40
|
|
|
34
41
|
if (entityId === null) return null;
|
|
35
42
|
const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
|
|
36
|
-
const assigned =
|
|
43
|
+
const assigned = assignments.data?.rows ?? [];
|
|
37
44
|
if (assigned.length === 0) return null;
|
|
38
45
|
|
|
39
46
|
return (
|
|
@@ -57,9 +57,16 @@ export function TagSection({
|
|
|
57
57
|
const dispatcher = useDispatcher();
|
|
58
58
|
const enabled = entityId !== null;
|
|
59
59
|
const catalog = useQuery<TagListResponse>(TagsQueries.tagList, {}, { enabled });
|
|
60
|
+
// entityType server-side, not just entityId: it collapses the parent-ref
|
|
61
|
+
// read gate to this one host entity instead of every registered one.
|
|
60
62
|
const assignments = useQuery<AssignmentListResponse>(
|
|
61
63
|
TagsQueries.assignmentList,
|
|
62
|
-
{
|
|
64
|
+
{
|
|
65
|
+
filters: [
|
|
66
|
+
{ field: "entityType", op: "eq", value: entityName },
|
|
67
|
+
{ field: "entityId", op: "eq", value: entityId },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
63
70
|
{ enabled },
|
|
64
71
|
);
|
|
65
72
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
@@ -93,9 +100,7 @@ export function TagSection({
|
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
const byId = new Map((catalog.data?.rows ?? []).map((tg) => [tg.id, tg]));
|
|
96
|
-
const assignedIds = (assignments.data?.rows ?? [])
|
|
97
|
-
.filter((r) => r.entityType === entityName)
|
|
98
|
-
.map((r) => r.tagId);
|
|
103
|
+
const assignedIds = (assignments.data?.rows ?? []).map((r) => r.tagId);
|
|
99
104
|
|
|
100
105
|
const refetch = async (): Promise<void> => {
|
|
101
106
|
await Promise.all([catalog.refetch(), assignments.refetch()]);
|