@cosmicdrift/kumiko-bundled-features 0.235.4 → 0.236.1
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 -9
- package/src/agent-tools/__tests__/agent-manifest.test.ts +6 -0
- package/src/agent-tools/__tests__/agent-tools.integration.test.ts +199 -0
- package/src/agent-tools/__tests__/tool-catalog.test.ts +340 -128
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +28 -16
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +390 -44
- package/src/agent-tools/agent-manifest.ts +3 -0
- package/src/agent-tools/index.ts +4 -2
- package/src/agent-tools/tool-catalog.ts +457 -17
- package/src/agent-tools/tool-dispatch.ts +334 -43
- package/src/agent-tools/types.ts +61 -1
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +55 -82
- package/src/feature-toggles/feature.ts +24 -15
- package/src/feature-toggles/index.ts +3 -0
- package/src/feature-toggles/toggle-runtime.ts +46 -8
- package/src/feature-toggles/toggle-sync-signal.ts +57 -0
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +202 -0
- package/src/notes-history/entity.ts +51 -32
- package/src/notes-history/feature.ts +39 -7
- package/src/notes-history-user-data/hooks.ts +12 -9
- package/src/shared/has-where-rule.ts +14 -0
- package/src/shared/index.ts +1 -0
- package/src/tags/__tests__/tags-ownership.test.ts +23 -0
- package/src/tags/entity.ts +26 -16
- package/src/tags/feature.ts +44 -5
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createEntity,
|
|
3
3
|
createLongTextField,
|
|
4
4
|
createTextField,
|
|
5
|
+
type EntityDefinition,
|
|
5
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
|
|
7
8
|
// note-entry — host-agnostic, append-only note attached to ANY entity via
|
|
@@ -14,35 +15,53 @@ import {
|
|
|
14
15
|
// Strictly append-only by design: no update/delete write-handler is
|
|
15
16
|
// registered (see feature.ts). A correction is a new entry, not an edit —
|
|
16
17
|
// that is the whole point of a note *history* instead of the single
|
|
17
|
-
// overwritable textarea this bundle replaces (solon#13).
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
18
|
+
// overwritable textarea this bundle replaces (solon#13).
|
|
19
|
+
//
|
|
20
|
+
// `body` is about the HOST entity (entityType/entityId), not the author —
|
|
21
|
+
// the author is never the data subject of a note's content, so it is
|
|
22
|
+
// deliberately NOT `personal: { of: "authorId" }`. That axis would
|
|
23
|
+
// crypto-shred every note's content the moment its author's data-rights key
|
|
24
|
+
// is destroyed (an employee leaving triggers a forget), garbling unrelated
|
|
25
|
+
// business notes about customers/projects/etc. — data loss, not privacy.
|
|
26
|
+
// `body` stays plaintext (`personal: false`), same call as tags' catalog
|
|
27
|
+
// `name` field. GDPR erasure of the AUTHOR still shreds `authorName`
|
|
28
|
+
// (`personal: { of: "authorId" }`, the name as stamped at write time) —
|
|
29
|
+
// `body` and the note itself are unaffected. If a note's body incidentally
|
|
30
|
+
// names or describes a third party, that isn't tracked by field annotation
|
|
31
|
+
// today; no PII hooks exist for note content.
|
|
32
|
+
export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
|
|
33
|
+
return createEntity({
|
|
34
|
+
table: "read_note_entries",
|
|
35
|
+
access,
|
|
36
|
+
fields: {
|
|
37
|
+
entityType: createTextField({ required: true, maxLength: 64 }),
|
|
38
|
+
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
39
|
+
entityId: createTextField({ required: true, maxLength: 128 }),
|
|
40
|
+
// Never client-supplied — stamped by the deriveAuthorId preSave hook from
|
|
41
|
+
// ctx.user.id (see feature.ts), so a note can't be authored as someone
|
|
42
|
+
// else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
|
|
43
|
+
// FK into `user`, not content of its own).
|
|
44
|
+
authorId: createTextField({
|
|
45
|
+
personal: "ref",
|
|
46
|
+
}),
|
|
47
|
+
// Stamped once at write time, never re-resolved later — the history
|
|
48
|
+
// needs the name as it was then, not whatever the account is called now.
|
|
49
|
+
authorName: createTextField({
|
|
50
|
+
maxLength: 200,
|
|
51
|
+
personal: { of: "authorId" },
|
|
52
|
+
find: "none",
|
|
53
|
+
}),
|
|
54
|
+
body: createLongTextField({
|
|
55
|
+
required: true,
|
|
56
|
+
maxLength: 20_000,
|
|
57
|
+
// Describes the host entity, not the author — no per-user subject to
|
|
58
|
+
// crypto-shred against. `personal: false` silences the
|
|
59
|
+
// user-content-name heuristic (`body` is on PII_USER_OWNED_NAME_HINTS).
|
|
60
|
+
personal: false,
|
|
61
|
+
reason: "note_content_not_owned_by_author",
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const noteEntryEntity = createNoteEntryEntity();
|
|
@@ -17,16 +17,19 @@ import {
|
|
|
17
17
|
type AccessRule,
|
|
18
18
|
defineEntityListHandler,
|
|
19
19
|
defineFeature,
|
|
20
|
+
type EntityDefinition,
|
|
20
21
|
type FeatureRegistrar,
|
|
21
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { hasWhereRule } from "../shared";
|
|
22
24
|
import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants";
|
|
23
|
-
import {
|
|
25
|
+
import { createNoteEntryEntity } from "./entity";
|
|
24
26
|
import { createAddNoteHandler } from "./handlers/add-note.write";
|
|
25
27
|
import { NOTES_HISTORY_FEATURE_I18N } from "./i18n";
|
|
26
28
|
|
|
27
29
|
function registerNotesHistory(
|
|
28
30
|
r: FeatureRegistrar<typeof NOTES_HISTORY_FEATURE_NAME>,
|
|
29
31
|
access: AccessRule,
|
|
32
|
+
ownership: EntityDefinition["access"] | undefined,
|
|
30
33
|
): void {
|
|
31
34
|
r.describe(
|
|
32
35
|
"Generic, host-agnostic, append-only note history for any entity. Owns one event-sourced entity, `note-entry` (`read_note_entries`), keyed by (entityType, entityId) — so attaching notes adds NO column to the host entity and needs no relational pivot or JOIN. Provides a `create` write-handler (author stamped server-side from the caller, never client-supplied) and a `list` query filterable on entityId. Deliberately append-only: no update or delete handler is registered — a correction is a new entry, not an edit, so who-said-what-when stays reconstructable. Every path uses one access rule — adopt the host's model with createNotesHistoryFeature({ access: { openToAll: true } }) or pin roles with createNotesHistoryFeature({ roles }).",
|
|
@@ -37,16 +40,17 @@ function registerNotesHistory(
|
|
|
37
40
|
recommended: false,
|
|
38
41
|
});
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
const entity = createNoteEntryEntity(ownership);
|
|
44
|
+
r.entity("note-entry", entity);
|
|
41
45
|
|
|
42
46
|
r.writeHandler(createAddNoteHandler(access));
|
|
43
|
-
r.queryHandler(defineEntityListHandler("note-entry",
|
|
47
|
+
r.queryHandler(defineEntityListHandler("note-entry", entity, { access }));
|
|
44
48
|
|
|
45
49
|
r.translations({ keys: NOTES_HISTORY_FEATURE_I18N });
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export const notesHistoryFeature = defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
|
|
49
|
-
registerNotesHistory(r, DEFAULT_NOTES_HISTORY_ACCESS),
|
|
53
|
+
registerNotesHistory(r, DEFAULT_NOTES_HISTORY_ACCESS, undefined),
|
|
50
54
|
);
|
|
51
55
|
|
|
52
56
|
export type NotesHistoryFeatureOptions = {
|
|
@@ -57,6 +61,22 @@ export type NotesHistoryFeatureOptions = {
|
|
|
57
61
|
readonly access?: AccessRule;
|
|
58
62
|
/** Shorthand for { access: { roles } }. Ignored when `access` is set. */
|
|
59
63
|
readonly roles?: readonly string[];
|
|
64
|
+
/** Row-level ownership on the note-entry rows themselves — orthogonal to
|
|
65
|
+
* `access`, which only gates whether a caller may dispatch create/list at
|
|
66
|
+
* all. Set `ownership.read` to close the read leak: without it — even if
|
|
67
|
+
* `ownership.write` is set — `access.read` stays undefined and any
|
|
68
|
+
* dispatch-eligible user can read every note in the tenant, including
|
|
69
|
+
* notes on entities they can't otherwise see.
|
|
70
|
+
*
|
|
71
|
+
* `ownership.write` is separate and does NOT affect list/read. It's
|
|
72
|
+
* consulted by the framework's generic delete/forget/restore paths (not
|
|
73
|
+
* by this feature's own add-note handler — see createNotesHistoryFeature's
|
|
74
|
+
* boot-guard comment). A `from()` rule there also gates GDPR erasure
|
|
75
|
+
* (`forget`): if the rule's role map doesn't cover whatever role the
|
|
76
|
+
* erasure/retention pipeline runs as, `forget` denies instead of
|
|
77
|
+
* crypto-shredding — a silent Art.17 failure, not a thrown error. Make
|
|
78
|
+
* sure any `ownership.write` you set covers that role, or leave it unset. */
|
|
79
|
+
readonly ownership?: EntityDefinition["access"];
|
|
60
80
|
};
|
|
61
81
|
|
|
62
82
|
function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
|
|
@@ -66,12 +86,24 @@ function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
|
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
// Options wrapper. Without options returns the module-level singleton (no
|
|
69
|
-
// rebuild). access/roles build a fresh feature-definition.
|
|
89
|
+
// rebuild). access/roles/ownership build a fresh feature-definition.
|
|
70
90
|
export function createNotesHistoryFeature(
|
|
71
91
|
opts: NotesHistoryFeatureOptions = {},
|
|
72
92
|
): typeof notesHistoryFeature {
|
|
73
|
-
if (opts.access === undefined && opts.roles === undefined)
|
|
93
|
+
if (opts.access === undefined && opts.roles === undefined && opts.ownership === undefined) {
|
|
94
|
+
return notesHistoryFeature;
|
|
95
|
+
}
|
|
96
|
+
if (hasWhereRule(opts.ownership?.write)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"createNotesHistoryFeature({ ownership }): ownership.write must not contain a " +
|
|
99
|
+
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
100
|
+
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
101
|
+
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
102
|
+
"evaluate them: create throws at runtime, update/delete/forget/restore " +
|
|
103
|
+
"silently deny. Use a `from()` rule for ownership.write, or leave it unset.",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
74
106
|
return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
|
|
75
|
-
registerNotesHistory(r, resolveAccess(opts)),
|
|
107
|
+
registerNotesHistory(r, resolveAccess(opts), opts.ownership),
|
|
76
108
|
);
|
|
77
109
|
}
|
|
@@ -27,16 +27,19 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
|
27
27
|
};
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
// Deliberate no-op:
|
|
31
|
-
// (entity.ts)
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
30
|
+
// Deliberate no-op: only `authorName` is annotated `personal: { of: "authorId" }`
|
|
31
|
+
// (entity.ts) — `body` is deliberately plaintext (`personal: false`), since it
|
|
32
|
+
// describes the HOST entity the note is attached to, not the author. So a
|
|
33
|
+
// forget here crypto-shreds (mounted KMS) just `authorName`: after erasing
|
|
34
|
+
// the author, who wrote a note is no longer visible, but the note's content
|
|
35
|
+
// stays intact and readable — the note is about the host entity, and other
|
|
36
|
+
// readers of that entity's history still need it. No physical delete is
|
|
37
|
+
// needed for either outcome.
|
|
36
38
|
// Same tradeoff as job-run/delivery-attempt (user-data-rights-defaults).
|
|
37
39
|
// Precondition: this ONLY erases anything if the app mounts a KMS adapter —
|
|
38
40
|
// without one, userOwned fields fall back to plaintext storage framework-wide
|
|
39
|
-
// (see pii-field-encryption.ts) and forget is a true no-op for `
|
|
40
|
-
// gap is a property of the framework's crypto-shredding design,
|
|
41
|
-
// to this hook; apps that need Art.17 coverage without KMS must
|
|
41
|
+
// (see pii-field-encryption.ts) and forget is a true no-op for `authorName`
|
|
42
|
+
// too. That gap is a property of the framework's crypto-shredding design,
|
|
43
|
+
// not specific to this hook; apps that need Art.17 coverage without KMS must
|
|
44
|
+
// mount one.
|
|
42
45
|
export const noteEntryDeleteHook: UserDataDeleteHook = async () => {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// `where`-rules are read-path only (buildOwnershipClause). On the write path
|
|
4
|
+
// userCanWriteFieldRow silently skips them (fail-closed deny), but
|
|
5
|
+
// userCanCreateFieldRow does NOT skip them — it runs matchesRule(), which
|
|
6
|
+
// throws for `kind: "where"` (no in-memory evaluator for raw SQL). An
|
|
7
|
+
// ownership.write map with a where-rule boots fine (the boot-validator has no
|
|
8
|
+
// where-specific handling either) and only blows up the first time a create
|
|
9
|
+
// hits it. Feature factories that accept an `ownership` option must reject
|
|
10
|
+
// this shape at build time instead of shipping the landmine.
|
|
11
|
+
export function hasWhereRule(map: OwnershipMap | undefined): boolean {
|
|
12
|
+
if (!map) return false;
|
|
13
|
+
return Object.values(map).some((rule) => rule !== "all" && rule.kind === "where");
|
|
14
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { decryptStoredPii } from "./decrypt-stored-pii";
|
|
|
13
13
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write";
|
|
14
14
|
export { entitiesOf } from "./entities-of";
|
|
15
15
|
export { isWithinGracePeriod } from "./grace-period";
|
|
16
|
+
export { hasWhereRule } from "./has-where-rule";
|
|
16
17
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
17
18
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
18
19
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// H.2 boot guard — mirrors notes-history-ownership.integration.test.ts's
|
|
2
|
+
// guard test. A where-rule in ownership.write boots fine (the boot-validator
|
|
3
|
+
// has no where-specific handling) and only blows up the first time
|
|
4
|
+
// assign-tag's create() hits it (userCanCreateFieldRow throws on
|
|
5
|
+
// `kind: "where"`, unlike update/delete/forget which silently deny). No DB
|
|
6
|
+
// needed — the guard fires at feature-construction time.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { createTagsFeature } from "../feature";
|
|
10
|
+
|
|
11
|
+
describe("tags — boot guard rejects a where-rule in ownership.write", () => {
|
|
12
|
+
test("createTagsFeature throws instead of shipping a create()-time landmine", () => {
|
|
13
|
+
expect(() =>
|
|
14
|
+
createTagsFeature({
|
|
15
|
+
ownership: {
|
|
16
|
+
write: {
|
|
17
|
+
TenantMember: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
).toThrow(/ownership\.write must not contain a.*where/);
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/tags/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
|
// tag — per-tenant tag catalog. Event-sourced entity (create/rename/delete via
|
|
4
8
|
// the standard executor); the framework projects `read_tags` from its own CRUD
|
|
@@ -7,11 +11,12 @@ export const tagEntity = createEntity({
|
|
|
7
11
|
table: "read_tags",
|
|
8
12
|
fields: {
|
|
9
13
|
// Catalog labels ("urgent", "billing"), not user-identifying content —
|
|
10
|
-
// `personal: false` silences the user-content heuristic (456/5). A
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// `personal: false` silences the user-content heuristic (456/5). A tag
|
|
15
|
+
// has no author to anchor a `personal: { of: "<f>" }` annotation to, and
|
|
16
|
+
// a tag isn't ABOUT the entity that created it anyway — it's a catalog
|
|
17
|
+
// entry shared across whatever gets assigned it. If a tenant ever names a
|
|
18
|
+
// tag after a real person, the fix is renaming or deleting that tag, not
|
|
19
|
+
// wiring it to a subject key.
|
|
15
20
|
name: createTextField({
|
|
16
21
|
required: true,
|
|
17
22
|
maxLength: 64,
|
|
@@ -48,13 +53,18 @@ export const tagEntity = createEntity({
|
|
|
48
53
|
// Cross-entity views compose in the read-layer (no JOIN):
|
|
49
54
|
// - tags of an entity → list assignments filter { field: "entityId", op: "eq" }
|
|
50
55
|
// - entities with a tag → list assignments filter { field: "tagId", op: "eq" }
|
|
51
|
-
export
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
56
|
+
export function createTagAssignmentEntity(access?: EntityDefinition["access"]) {
|
|
57
|
+
return createEntity({
|
|
58
|
+
table: "read_tag_assignments",
|
|
59
|
+
softDelete: true,
|
|
60
|
+
access,
|
|
61
|
+
fields: {
|
|
62
|
+
tagId: createTextField({ required: true, maxLength: 64 }),
|
|
63
|
+
entityType: createTextField({ required: true, maxLength: 64 }),
|
|
64
|
+
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
65
|
+
entityId: createTextField({ required: true, maxLength: 128 }),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const tagAssignmentEntity = createTagAssignmentEntity();
|
package/src/tags/feature.ts
CHANGED
|
@@ -24,10 +24,12 @@ import {
|
|
|
24
24
|
defineEntityListHandler,
|
|
25
25
|
defineEntityUpdateHandler,
|
|
26
26
|
defineFeature,
|
|
27
|
+
type EntityDefinition,
|
|
27
28
|
type FeatureRegistrar,
|
|
28
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
+
import { hasWhereRule } from "../shared";
|
|
29
31
|
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
|
|
30
|
-
import {
|
|
32
|
+
import { createTagAssignmentEntity, tagEntity } from "./entity";
|
|
31
33
|
import { createAssignTagHandler } from "./handlers/assign-tag.write";
|
|
32
34
|
import { createCreateTagHandler } from "./handlers/create-tag.write";
|
|
33
35
|
import { createDeleteTagHandler } from "./handlers/delete-tag.write";
|
|
@@ -48,6 +50,7 @@ function registerTags(
|
|
|
48
50
|
r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>,
|
|
49
51
|
access: AccessRule,
|
|
50
52
|
toggleable: TagsToggleable | undefined,
|
|
53
|
+
ownership: EntityDefinition["access"] | undefined,
|
|
51
54
|
): void {
|
|
52
55
|
r.describe(
|
|
53
56
|
"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).",
|
|
@@ -62,6 +65,7 @@ function registerTags(
|
|
|
62
65
|
// feature toggleable lets tier-engine/feature-toggles cut it per tenant.
|
|
63
66
|
if (toggleable !== undefined) r.toggleable(toggleable);
|
|
64
67
|
|
|
68
|
+
const tagAssignmentEntity = createTagAssignmentEntity(ownership);
|
|
65
69
|
r.entity("tag", tagEntity);
|
|
66
70
|
r.entity("tag-assignment", tagAssignmentEntity);
|
|
67
71
|
|
|
@@ -91,7 +95,7 @@ function registerTags(
|
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
94
|
-
registerTags(r, DEFAULT_TAG_ACCESS, undefined),
|
|
98
|
+
registerTags(r, DEFAULT_TAG_ACCESS, undefined, undefined),
|
|
95
99
|
);
|
|
96
100
|
|
|
97
101
|
export type TagsFeatureOptions = {
|
|
@@ -107,6 +111,23 @@ export type TagsFeatureOptions = {
|
|
|
107
111
|
* `default` applies when no toggle/tier override exists — use { default: false }
|
|
108
112
|
* for fail-closed tier-gating. Omit to keep tags always-on (default). */
|
|
109
113
|
readonly toggleable?: TagsToggleable;
|
|
114
|
+
/** Row-level ownership on the tag-assignment rows themselves — orthogonal to
|
|
115
|
+
* `access`, which only gates whether a caller may dispatch assign/remove/list
|
|
116
|
+
* at all. Set `ownership.read` to close the read leak: without it — even if
|
|
117
|
+
* `ownership.write` is set — `access.read` stays undefined and any
|
|
118
|
+
* dispatch-eligible user can read every assignment in the tenant, including
|
|
119
|
+
* assignments on entities they can't otherwise see. Applies only to
|
|
120
|
+
* `tag-assignment`; the `tag` catalog stays tenant-wide by design.
|
|
121
|
+
*
|
|
122
|
+
* `ownership.write` is separate and does NOT affect list/read. It's
|
|
123
|
+
* consulted by the framework's generic delete/forget/restore paths (not by
|
|
124
|
+
* this feature's own assign-tag handler — see createTagsFeature's
|
|
125
|
+
* boot-guard comment). A `from()` rule there also gates GDPR erasure
|
|
126
|
+
* (`forget`): if the rule's role map doesn't cover whatever role the
|
|
127
|
+
* erasure/retention pipeline runs as, `forget` denies instead of
|
|
128
|
+
* crypto-shredding — a silent Art.17 failure, not a thrown error. Make
|
|
129
|
+
* sure any `ownership.write` you set covers that role, or leave it unset. */
|
|
130
|
+
readonly ownership?: EntityDefinition["access"];
|
|
110
131
|
};
|
|
111
132
|
|
|
112
133
|
function resolveAccess(opts: TagsFeatureOptions): AccessRule {
|
|
@@ -116,11 +137,29 @@ function resolveAccess(opts: TagsFeatureOptions): AccessRule {
|
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
// Backwards-compat / options wrapper. Without options returns the module-level
|
|
119
|
-
// singleton (no rebuild). access/roles/toggleable build a fresh
|
|
140
|
+
// singleton (no rebuild). access/roles/toggleable/ownership build a fresh
|
|
141
|
+
// feature-definition.
|
|
120
142
|
export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFeature {
|
|
121
|
-
if (
|
|
143
|
+
if (
|
|
144
|
+
opts.access === undefined &&
|
|
145
|
+
opts.roles === undefined &&
|
|
146
|
+
opts.toggleable === undefined &&
|
|
147
|
+
opts.ownership === undefined
|
|
148
|
+
) {
|
|
122
149
|
return tagsFeature;
|
|
123
150
|
}
|
|
151
|
+
if (hasWhereRule(opts.ownership?.write)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"createTagsFeature({ ownership }): ownership.write must not contain a " +
|
|
154
|
+
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
155
|
+
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
156
|
+
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
157
|
+
"evaluate them: create throws at runtime, update/delete/forget/restore " +
|
|
158
|
+
"silently deny. Use a `from()` rule for ownership.write, or leave it unset.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
124
161
|
const access = resolveAccess(opts);
|
|
125
|
-
return defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
162
|
+
return defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
163
|
+
registerTags(r, access, opts.toggleable, opts.ownership),
|
|
164
|
+
);
|
|
126
165
|
}
|