@cosmicdrift/kumiko-bundled-features 0.235.4 → 0.236.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.
@@ -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). GDPR erasure of the
18
- // author still works without a delete path: `body` is `personal: { of:
19
- // "authorId" }` (crypto-shredding on the author's subject key), not
20
- // `personal: "self"` on the entity itself.
21
- export const noteEntryEntity = createEntity({
22
- table: "read_note_entries",
23
- fields: {
24
- entityType: createTextField({ required: true, maxLength: 64 }),
25
- // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
26
- entityId: createTextField({ required: true, maxLength: 128 }),
27
- // Never client-supplied — stamped by the deriveAuthorId preSave hook from
28
- // ctx.user.id (see feature.ts), so a note can't be authored as someone
29
- // else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
30
- // FK into `user`, not content of its own).
31
- authorId: createTextField({
32
- personal: "ref",
33
- }),
34
- // Stamped once at write time, never re-resolved later — the history
35
- // needs the name as it was then, not whatever the account is called now.
36
- authorName: createTextField({
37
- maxLength: 200,
38
- personal: { of: "authorId" },
39
- find: "none",
40
- }),
41
- body: createLongTextField({
42
- required: true,
43
- maxLength: 20_000,
44
- personal: { of: "authorId" },
45
- find: "none",
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 { noteEntryEntity } from "./entity";
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
- r.entity("note-entry", noteEntryEntity);
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", noteEntryEntity, { access }));
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) return notesHistoryFeature;
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: `body` and `authorName` are annotated `personal: { of: "authorId" }`
31
- // (entity.ts), which relies on crypto-shredding (mounted KMS) for erasure —
32
- // destroying the author's subject key makes every note-entry event AND
33
- // projected row unreadable at once, without needing a physical delete
34
- // (which would also break the bundle's append-only history for OTHER
35
- // entities' co-authors reading it).
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 `body`. That
40
- // gap is a property of the framework's crypto-shredding design, not specific
41
- // to this hook; apps that need Art.17 coverage without KMS must mount one.
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
+ }
@@ -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
+ });
@@ -1,4 +1,8 @@
1
- import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ createEntity,
3
+ createTextField,
4
+ type EntityDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
 
3
7
  // 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
- // tenant COULD name a tag after a person; if that becomes a real
12
- // requirement, this needs a `personal: { of: "<f>" }` annotation +
13
- // forget/export hooks in the user-data-rights pipeline (none exist for
14
- // tags today).
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 const tagAssignmentEntity = createEntity({
52
- table: "read_tag_assignments",
53
- softDelete: true,
54
- fields: {
55
- tagId: createTextField({ required: true, maxLength: 64 }),
56
- entityType: createTextField({ required: true, maxLength: 64 }),
57
- // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
58
- entityId: createTextField({ required: true, maxLength: 128 }),
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();
@@ -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 { tagAssignmentEntity, tagEntity } from "./entity";
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 feature-definition.
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 (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
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) => registerTags(r, access, opts.toggleable));
162
+ return defineFeature(TAGS_FEATURE_NAME, (r) =>
163
+ registerTags(r, access, opts.toggleable, opts.ownership),
164
+ );
126
165
  }