@cosmicdrift/kumiko-bundled-features 0.355.0 → 0.357.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.
Files changed (88) hide show
  1. package/dist/auth-mfa/i18n.js +1 -0
  2. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  3. package/dist/auth-mfa/screens.js +8 -1
  4. package/dist/billing-foundation/entities.d.ts +8 -0
  5. package/dist/billing-foundation/projection.d.ts +8 -0
  6. package/dist/cap-counter/entity.d.ts +1 -0
  7. package/dist/cap-counter/feature.js +7 -1
  8. package/dist/cap-overview/screens.js +3 -1
  9. package/dist/compliance-profiles/handlers/set-profile.write.js +14 -22
  10. package/dist/config/table.d.ts +6 -0
  11. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -3
  12. package/dist/custom-fields/entity.d.ts +4 -0
  13. package/dist/custom-fields/handlers/define-system-field.write.js +8 -16
  14. package/dist/custom-fields/handlers/delete-system-field.write.js +5 -2
  15. package/dist/custom-fields/lib/define-or-resurrect.d.ts +2 -1
  16. package/dist/custom-fields/lib/define-or-resurrect.js +11 -4
  17. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  18. package/dist/delivery/chat-webhook-sender.js +1 -1
  19. package/dist/delivery/constants.d.ts +1 -0
  20. package/dist/delivery/constants.js +4 -0
  21. package/dist/delivery/delivery-service.js +62 -25
  22. package/dist/delivery/feature.js +7 -1
  23. package/dist/delivery/jobs.js +25 -1
  24. package/dist/delivery/tables.d.ts +6 -0
  25. package/dist/delivery/testing.d.ts +1 -0
  26. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  27. package/dist/folders/entity.d.ts +8 -0
  28. package/dist/form-draft/entity.d.ts +2 -0
  29. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  30. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  31. package/dist/jobs/job-run-table.d.ts +6 -0
  32. package/dist/ledger/entity.d.ts +13 -0
  33. package/dist/managed-pages/handlers/set.write.js +6 -17
  34. package/dist/managed-pages/table.d.ts +12 -0
  35. package/dist/notes-history/entity.d.ts +12 -0
  36. package/dist/notes-history/entity.js +7 -6
  37. package/dist/notes-history-user-data/hooks.d.ts +10 -1
  38. package/dist/notes-history-user-data/hooks.js +78 -61
  39. package/dist/notes-history-user-data/index.d.ts +1 -0
  40. package/dist/notes-history-user-data/index.js +2 -1
  41. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  42. package/dist/personal-access-tokens/screens.js +2 -0
  43. package/dist/secrets/table.d.ts +1 -0
  44. package/dist/sessions/schema/user-session.d.ts +4 -0
  45. package/dist/sessions/screens.js +2 -0
  46. package/dist/shared/index.d.ts +1 -0
  47. package/dist/shared/index.js +1 -0
  48. package/dist/shared/require-for-tenant.d.ts +2 -0
  49. package/dist/shared/require-for-tenant.js +9 -0
  50. package/dist/tags/entity.d.ts +9 -0
  51. package/dist/template-resolver/handlers/collection-set.write.js +4 -4
  52. package/dist/template-resolver/handlers/set.write.js +6 -7
  53. package/dist/template-resolver/handlers/upsert-system.write.js +6 -6
  54. package/dist/template-resolver/handlers/upsert-tenant.write.js +6 -4
  55. package/dist/template-resolver/table.d.ts +10 -0
  56. package/dist/template-resolver/user-content-table.d.ts +10 -0
  57. package/dist/tenant/cancel-pending-invitation.d.ts +2 -2
  58. package/dist/tenant/cancel-pending-invitation.js +2 -2
  59. package/dist/tenant/constants.d.ts +1 -0
  60. package/dist/tenant/constants.js +6 -3
  61. package/dist/tenant/feature.js +2 -0
  62. package/dist/tenant/handlers/member-role-availability.query.d.ts +7 -0
  63. package/dist/tenant/handlers/member-role-availability.query.js +18 -0
  64. package/dist/tenant/handlers/remove-member.write.js +13 -10
  65. package/dist/tenant/handlers/update-member-roles.write.js +12 -9
  66. package/dist/tenant/invitation-table.d.ts +6 -0
  67. package/dist/tenant/member-role-options.d.ts +2 -0
  68. package/dist/tenant/member-role-options.js +4 -0
  69. package/dist/tenant/membership-table.d.ts +4 -0
  70. package/dist/tenant/schema/tenant.d.ts +6 -0
  71. package/dist/tenant/screens.d.ts +4 -2
  72. package/dist/tenant/screens.js +10 -6
  73. package/dist/tier-engine/entity.d.ts +2 -0
  74. package/dist/tier-engine/handlers/set-tenant-tier.write.js +7 -18
  75. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  76. package/dist/user/schema/user.d.ts +12 -0
  77. package/dist/user/screens.js +8 -1
  78. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  79. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  80. package/dist/user-data-rights/schema/export-job.d.ts +6 -0
  81. package/dist/user-data-rights/screens.js +8 -1
  82. package/dist/waitlist/entity.d.ts +14 -0
  83. package/dist/waitlist/screens.js +8 -1
  84. package/package.json +9 -9
  85. package/src/auth-mfa/changes.json +6 -0
  86. package/src/crypto-shredding/changes.json +6 -0
  87. package/src/delivery/changes.json +6 -0
  88. package/src/tenant/changes.json +6 -0
@@ -3,6 +3,7 @@
3
3
  // notes consumers without the user-data-rights pipeline don't pull a hard
4
4
  // dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
5
5
  // export-only, erasure via crypto-shredding.
6
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
7
  import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
7
8
  import { resolveRetentionPolicyForTenant, resolveTenantRetentionPreset, } from "../data-retention/index.js";
8
9
  import { noteEntryTable, noteMentionTable } from "../notes-history/index.js";
@@ -26,93 +27,109 @@ export const noteEntryExportHook = async (ctx) => {
26
27
  })),
27
28
  };
28
29
  };
30
+ // What erasing a subject's key reaches: notes that @-mention the subject plus notes
31
+ // attached to it as host. Export and erasure both go through this, so Art. 15
32
+ // lists exactly what Art. 17 shreds.
33
+ async function selectNotesReachingSubject(db, subject) {
34
+ if (subject.kind === "tenant")
35
+ return [];
36
+ const subjectId = subject.kind === "user" ? subject.userId : subject.id;
37
+ const attachedWhere = subject.kind === "record"
38
+ ? { entityType: subject.entity, entityId: subjectId }
39
+ : { entityId: subjectId };
40
+ const mentions = await selectMany(db, noteMentionTable, { subjectId });
41
+ const mentioned = mentions.length === 0
42
+ ? []
43
+ : await selectMany(db, noteEntryTable, {
44
+ id: { in: [...new Set(mentions.map((m) => m.noteId))] },
45
+ });
46
+ const attached = await selectMany(db, noteEntryTable, attachedWhere);
47
+ const byId = new Map();
48
+ for (const note of [...mentioned, ...attached])
49
+ byId.set(note.id, note);
50
+ return [...byId.values()];
51
+ }
29
52
  // `authorName` is separately annotated `personal: { of: "authorId" }`
30
53
  // (entity.ts), so forgetting the AUTHOR already crypto-shreds that field on
31
- // its own — unrelated to this hook. `body` is Row-Subject (`personal: { of:
32
- // "id" }`): its key is per-note, not per-user, so forgetting a user only
33
- // reaches a note's body when that note structurally @-mentions the user via
34
- // a `note-mention` row (fw#2787). Free-text mentions are NOT detected —
35
- // that's the documented gap in entity.ts, not a bug here.
54
+ // its own. `body` is Row-Subject (`personal: { of: "id" }`): its key is per
55
+ // note, so forgetting a subject only reaches a body when the note structurally
56
+ // @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
57
+ // Free-text mentions are NOT detected — the documented gap in entity.ts.
36
58
  //
37
- // Per reached note: consult the HOST entity's (entityType, not note-entry's
38
- // own) retention strategy first — blockDelete/anonymize must win over
39
- // erasure, same rule the automated forget-cleanup pipeline applies to every
40
- // other entity. The `strategy` this hook itself is called with is resolved
41
- // for note-entry, not the host, so it is deliberately unused here.
59
+ // Per reached note the HOST entity's retention strategy (entityType, resolved
60
+ // for the note's own tenant — the manual forget-subject path runs cross-tenant)
61
+ // is consulted first: anonymize/blockDelete win over erasure, same rule the
62
+ // automated forget-cleanup applies to every other entity.
42
63
  //
43
- // Precondition: this ONLY erases anything if the app mounts a KMS adapter —
44
- // without one, record-owned fields fall back to plaintext storage
45
- // framework-wide (see pii-field-encryption.ts) and forget is a true no-op.
46
- // That gap is a property of the framework's crypto-shredding design, not
47
- // specific to this hook; apps that need Art.17 coverage without KMS must
48
- // mount one.
49
- export const noteEntryDeleteHook = async (ctx) => {
64
+ // Without a mounted KMS this erases nothing: record-owned fields fall back to
65
+ // plaintext framework-wide (see pii-field-encryption.ts), so apps that need
66
+ // Art. 17 coverage must mount one.
67
+ export async function eraseNoteBodiesReachingSubject(args) {
50
68
  const kms = configuredPiiSubjectKms();
51
- // skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
69
+ // skip: no KMS adapter mounted — forget is a true no-op
52
70
  if (!kms)
53
71
  return;
54
- const mentions = await ctx.db.selectMany(noteMentionTable, {
55
- subjectId: ctx.userId,
56
- });
57
- // skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
58
- if (mentions.length === 0)
59
- return;
60
- const noteIds = new Set(mentions.map((m) => m.noteId));
61
- // Once per hook run: the preset depends on the tenant only, not the note.
62
- const tenantPreset = await resolveTenantRetentionPreset({
63
- db: ctx.db,
64
- registry: ctx.registry,
65
- tenantId: ctx.tenantId,
66
- });
67
- // Append-only rows are never hard-deleted, so every mentioned note should
68
- // resolve; a missing one has no host and thus no retention policy to consult.
69
- const notes = await ctx.db.selectMany(noteEntryTable, {
70
- id: { in: [...noteIds] },
71
- });
72
- const strategyByEntityType = new Map();
72
+ const notes = await selectNotesReachingSubject(args.db, args.subject);
73
+ const dataRetentionMounted = args.registry.features.has("data-retention");
74
+ const presetByTenant = new Map();
75
+ const strategyByTenantAndEntityType = new Map();
73
76
  for (const note of notes) {
74
- let strategy = strategyByEntityType.get(note.entityType);
77
+ const strategyKey = `${note.tenantId}:${note.entityType}`;
78
+ let strategy = strategyByTenantAndEntityType.get(strategyKey);
75
79
  if (strategy === undefined) {
80
+ let tenantPreset = presetByTenant.get(note.tenantId);
81
+ if (tenantPreset === undefined) {
82
+ tenantPreset = await resolveTenantRetentionPreset({
83
+ db: args.db,
84
+ registry: args.registry,
85
+ tenantId: note.tenantId,
86
+ });
87
+ presetByTenant.set(note.tenantId, tenantPreset);
88
+ }
76
89
  const hostPolicy = await resolveRetentionPolicyForTenant({
77
- db: ctx.db,
78
- registry: ctx.registry,
79
- tenantId: ctx.tenantId,
90
+ db: args.db,
91
+ registry: args.registry,
92
+ tenantId: note.tenantId,
80
93
  entityName: note.entityType,
81
94
  preloadedTenantPreset: tenantPreset,
95
+ // Without data-retention there is no override table to read.
96
+ ...(!dataRetentionMounted && { preloadedOverride: null }),
82
97
  });
83
98
  strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
84
- strategyByEntityType.set(note.entityType, strategy);
99
+ strategyByTenantAndEntityType.set(strategyKey, strategy);
85
100
  }
86
101
  if (strategy === "anonymize")
87
102
  continue;
88
- const noteId = note.id;
89
- await kms.eraseKey({ kind: "record", entity: "note-entry", id: noteId }, {
90
- requestId: "notes-history-user-data:forget-mentioned-notes",
91
- userId: ctx.userId,
92
- eraseReason: "notes-history:mention-forget",
103
+ await kms.eraseKey({ kind: "record", entity: "note-entry", id: note.id }, {
104
+ requestId: args.requestId,
105
+ userId: args.erasedBy,
106
+ eraseReason: "notes-history:subject-forget",
93
107
  });
94
108
  }
109
+ }
110
+ export const noteEntryDeleteHook = async (ctx) => {
111
+ await eraseNoteBodiesReachingSubject({
112
+ db: ctx.db,
113
+ registry: ctx.registry,
114
+ subject: { kind: "user", userId: ctx.userId },
115
+ erasedBy: ctx.userId,
116
+ requestId: "notes-history-user-data:forget-reached-notes",
117
+ });
95
118
  };
96
- // Erasure reaches every note that structurally mentions the user (see
97
- // noteEntryDeleteHook), so the Art. 15/20 export must list those notes too.
119
+ // Erasure reaches every note that mentions the user or hangs on the user as host
120
+ // (see eraseNoteBodiesReachingSubject), so the Art. 15/20 export lists those too.
98
121
  export const noteMentionExportHook = async (ctx) => {
99
- const mentions = await ctx.db.selectMany(noteMentionTable, {
100
- subjectId: ctx.userId,
101
- });
102
- if (mentions.length === 0)
103
- return null;
104
- const noteIds = [...new Set(mentions.map((m) => m.noteId))];
105
- const notes = await ctx.db.selectMany(noteEntryTable, { id: { in: noteIds } });
122
+ const notes = await selectNotesReachingSubject(ctx.db, { kind: "user", userId: ctx.userId });
106
123
  if (notes.length === 0)
107
124
  return null;
108
125
  return {
109
126
  entity: "note-mention",
110
127
  rows: notes.map((note) => ({
111
- noteId: note["id"],
112
- entityType: note["entityType"],
113
- entityId: note["entityId"],
114
- body: note["body"],
115
- insertedAt: note["insertedAt"],
128
+ noteId: note.id,
129
+ entityType: note.entityType,
130
+ entityId: note.entityId,
131
+ body: note.body,
132
+ insertedAt: note.insertedAt,
116
133
  })),
117
134
  };
118
135
  };
@@ -1,3 +1,4 @@
1
+ export { eraseNoteBodiesReachingSubject } from "./hooks.js";
1
2
  export declare const notesHistoryUserDataFeature: import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
2
3
  readonly exports: void;
3
4
  };
@@ -6,8 +6,9 @@
6
6
  // user-data-rights stack. Mirrors folders-user-data.
7
7
  import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { noteEntryDeleteHook, noteEntryExportHook, noteMentionDeleteHook, noteMentionExportHook, } from "./hooks.js";
9
+ export { eraseNoteBodiesReachingSubject } from "./hooks.js";
9
10
  export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
10
- r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook looks up `note-mention` for notes that structurally @-mention the forgotten user and crypto-shreds each reached note's row-subject key (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for notes without such a mention. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.");
11
+ r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.");
11
12
  // user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
12
13
  // is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
13
14
  // would throw an "effectively disabled" boot warning even though the
@@ -4,6 +4,7 @@ export declare const apiTokenEntity: import("@cosmicdrift/kumiko-types/fields").
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const apiTokenEntity: import("@cosmicdrift/kumiko-types/fields").
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const apiTokenEntity: import("@cosmicdrift/kumiko-types/fields").
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -70,6 +73,7 @@ export declare const apiTokenEntity: import("@cosmicdrift/kumiko-types/fields").
70
73
  readonly description?: string;
71
74
  readonly maxLength?: number;
72
75
  readonly minLength?: number;
76
+ readonly autoComplete?: string;
73
77
  readonly required?: boolean;
74
78
  readonly searchable?: boolean;
75
79
  readonly sortable?: boolean;
@@ -92,6 +96,7 @@ export declare const apiTokenEntity: import("@cosmicdrift/kumiko-types/fields").
92
96
  readonly description?: string;
93
97
  readonly maxLength?: number;
94
98
  readonly minLength?: number;
99
+ readonly autoComplete?: string;
95
100
  readonly required?: boolean;
96
101
  readonly searchable?: boolean;
97
102
  readonly sortable?: boolean;
@@ -114,6 +119,7 @@ export declare const apiTokenEntity: import("@cosmicdrift/kumiko-types/fields").
114
119
  readonly description?: string;
115
120
  readonly maxLength?: number;
116
121
  readonly minLength?: number;
122
+ readonly autoComplete?: string;
117
123
  readonly required?: boolean;
118
124
  readonly searchable?: boolean;
119
125
  readonly sortable?: boolean;
@@ -41,11 +41,13 @@ export const patListScreen = {
41
41
  {
42
42
  field: "createdAt",
43
43
  label: i18nKey("pat.list.col.created"),
44
+ hideOnNarrow: true,
44
45
  renderer: { format: "timestamp" },
45
46
  },
46
47
  {
47
48
  field: "expiresAt",
48
49
  label: i18nKey("pat.list.col.expires"),
50
+ hideOnNarrow: true,
49
51
  renderer: { format: "timestamp" },
50
52
  },
51
53
  ],
@@ -9,6 +9,7 @@ export declare const tenantSecretEntity: import("@cosmicdrift/kumiko-types/field
9
9
  readonly description?: string;
10
10
  readonly maxLength?: number;
11
11
  readonly minLength?: number;
12
+ readonly autoComplete?: string;
12
13
  readonly required?: boolean;
13
14
  readonly searchable?: boolean;
14
15
  readonly sortable?: boolean;
@@ -4,6 +4,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -108,6 +110,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
108
110
  readonly description?: string;
109
111
  readonly maxLength?: number;
110
112
  readonly minLength?: number;
113
+ readonly autoComplete?: string;
111
114
  readonly required?: boolean;
112
115
  readonly searchable?: boolean;
113
116
  readonly sortable?: boolean;
@@ -130,6 +133,7 @@ export declare const userSessionEntity: import("@cosmicdrift/kumiko-types/fields
130
133
  readonly description?: string;
131
134
  readonly maxLength?: number;
132
135
  readonly minLength?: number;
136
+ readonly autoComplete?: string;
133
137
  readonly required?: boolean;
134
138
  readonly searchable?: boolean;
135
139
  readonly sortable?: boolean;
@@ -33,6 +33,7 @@ export const createSessionListScreen = (adminRoles = access.admin) => ({
33
33
  {
34
34
  field: "revokedAt",
35
35
  label: i18nKey("sessions.list.col.revokedAt"),
36
+ hideOnNarrow: true,
36
37
  renderer: { format: "timestamp" },
37
38
  },
38
39
  ],
@@ -104,6 +105,7 @@ export const sessionMineScreen = {
104
105
  {
105
106
  field: "current",
106
107
  label: i18nKey("sessions.mine.col.current"),
108
+ hideOnNarrow: true,
107
109
  renderer: { format: "boolean" },
108
110
  },
109
111
  ],
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
16
16
  export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
17
+ export { requireForTenant } from "./require-for-tenant.js";
17
18
  export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
18
19
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
19
20
  export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
16
16
  export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
17
+ export { requireForTenant } from "./require-for-tenant.js";
17
18
  export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
18
19
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
19
20
  export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
@@ -0,0 +1,2 @@
1
+ import type { HandlerContext, TenantId, TenantWriteTarget } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function requireForTenant(ctx: Pick<HandlerContext, "forTenant">, targetTenantId: TenantId): TenantWriteTarget;
@@ -0,0 +1,9 @@
1
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
2
+ // ctx.forTenant is absent for r.systemScope() handlers and without a db; a write handler that
3
+ // needs a cross-tenant target must fail loudly instead of falling back to ctx.db.
4
+ export function requireForTenant(ctx, targetTenantId) {
5
+ if (!ctx.forTenant) {
6
+ throw new InternalError({ message: "ctx.forTenant is not available in this handler context" });
7
+ }
8
+ return ctx.forTenant(targetTenantId);
9
+ }
@@ -5,6 +5,7 @@ export declare const tagEntity: EntityDefinition<{
5
5
  readonly description?: string;
6
6
  readonly maxLength?: number;
7
7
  readonly minLength?: number;
8
+ readonly autoComplete?: string;
8
9
  readonly required?: boolean;
9
10
  readonly searchable?: boolean;
10
11
  readonly sortable?: boolean;
@@ -27,6 +28,7 @@ export declare const tagEntity: EntityDefinition<{
27
28
  readonly description?: string;
28
29
  readonly maxLength?: number;
29
30
  readonly minLength?: number;
31
+ readonly autoComplete?: string;
30
32
  readonly required?: boolean;
31
33
  readonly searchable?: boolean;
32
34
  readonly sortable?: boolean;
@@ -49,6 +51,7 @@ export declare const tagEntity: EntityDefinition<{
49
51
  readonly description?: string;
50
52
  readonly maxLength?: number;
51
53
  readonly minLength?: number;
54
+ readonly autoComplete?: string;
52
55
  readonly required?: boolean;
53
56
  readonly searchable?: boolean;
54
57
  readonly sortable?: boolean;
@@ -73,6 +76,7 @@ export declare function createTagAssignmentEntity(access?: EntityDefinition["acc
73
76
  readonly description?: string;
74
77
  readonly maxLength?: number;
75
78
  readonly minLength?: number;
79
+ readonly autoComplete?: string;
76
80
  readonly required?: boolean;
77
81
  readonly searchable?: boolean;
78
82
  readonly sortable?: boolean;
@@ -95,6 +99,7 @@ export declare function createTagAssignmentEntity(access?: EntityDefinition["acc
95
99
  readonly description?: string;
96
100
  readonly maxLength?: number;
97
101
  readonly minLength?: number;
102
+ readonly autoComplete?: string;
98
103
  readonly required?: boolean;
99
104
  readonly searchable?: boolean;
100
105
  readonly sortable?: boolean;
@@ -117,6 +122,7 @@ export declare function createTagAssignmentEntity(access?: EntityDefinition["acc
117
122
  readonly description?: string;
118
123
  readonly maxLength?: number;
119
124
  readonly minLength?: number;
125
+ readonly autoComplete?: string;
120
126
  readonly required?: boolean;
121
127
  readonly searchable?: boolean;
122
128
  readonly sortable?: boolean;
@@ -141,6 +147,7 @@ export declare const tagAssignmentEntity: EntityDefinition<{
141
147
  readonly description?: string;
142
148
  readonly maxLength?: number;
143
149
  readonly minLength?: number;
150
+ readonly autoComplete?: string;
144
151
  readonly required?: boolean;
145
152
  readonly searchable?: boolean;
146
153
  readonly sortable?: boolean;
@@ -163,6 +170,7 @@ export declare const tagAssignmentEntity: EntityDefinition<{
163
170
  readonly description?: string;
164
171
  readonly maxLength?: number;
165
172
  readonly minLength?: number;
173
+ readonly autoComplete?: string;
166
174
  readonly required?: boolean;
167
175
  readonly searchable?: boolean;
168
176
  readonly sortable?: boolean;
@@ -185,6 +193,7 @@ export declare const tagAssignmentEntity: EntityDefinition<{
185
193
  readonly description?: string;
186
194
  readonly maxLength?: number;
187
195
  readonly minLength?: number;
196
+ readonly autoComplete?: string;
188
197
  readonly required?: boolean;
189
198
  readonly searchable?: boolean;
190
199
  readonly sortable?: boolean;
@@ -1,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { requireForTenant } from "../../shared/index.js";
4
5
  import { collectionStore, DEFAULT_COLLECTION_ACCESS, } from "./collection-shared.js";
5
6
  import { contentFormatSchema, folderSchema, localeSchema, slugSchema } from "./shared.js";
6
7
  // Upsert inside one collection. Mirrors set.write for text-blocks, but the
@@ -29,12 +30,11 @@ export function makeCollectionSetWrite(collection) {
29
30
  ? `Creates or overwrites the caller's own "${collection.id}" entry at a slug and locale, live on save with no draft stage; it always writes the acting user's entry, never another user's.`
30
31
  : `Creates or overwrites the tenant-wide "${collection.id}" entry at a slug and locale, live on save with no draft stage; the change is visible to everyone who may reach this shared collection.`,
31
32
  handler: async (event, ctx) => {
32
- const db = ctx.db;
33
33
  const override = event.payload.tenantIdOverride;
34
34
  // @cast-boundary engine-payload — override is a zod-validated string, the
35
35
  // user's own tenantId is already TenantId-branded.
36
36
  const tenantId = (override ?? event.user.tenantId);
37
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
37
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
38
38
  // Scoped to the acting user even under a tenant override: a SystemAdmin
39
39
  // writing into another tenant still writes their own entry, never
40
40
  // someone else's signature.
@@ -59,7 +59,7 @@ export function makeCollectionSetWrite(collection) {
59
59
  contentFormat: event.payload.contentFormat,
60
60
  folder: event.payload.folder ?? null,
61
61
  },
62
- }, executorUser, db);
62
+ }, event.user, db, { streamTenantId });
63
63
  if (!result.isSuccess)
64
64
  return result;
65
65
  return {
@@ -78,7 +78,7 @@ export function makeCollectionSetWrite(collection) {
78
78
  folder: event.payload.folder ?? null,
79
79
  ...store.createDefaults(tenantId),
80
80
  ...owner,
81
- }, executorUser, db);
81
+ }, event.user, db, { streamTenantId });
82
82
  if (!result.isSuccess)
83
83
  return result;
84
84
  return {
@@ -1,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { requireForTenant } from "../../shared/index.js";
4
5
  import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants.js";
5
6
  import { templateResourcesTable } from "../table.js";
6
7
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.js";
@@ -42,15 +43,11 @@ export const setWrite = defineWriteHandler({
42
43
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
43
44
  description: "Creates or overwrites one content-tree resource addressed by slug, kind and locale, carrying a title and folder and going live on save with no draft stage; a SystemAdmin may target the system tenant, where shared legal and marketing copy lives.",
44
45
  handler: async (event, ctx) => {
45
- const db = ctx.db;
46
46
  const override = event.payload.tenantIdOverride;
47
47
  // @cast-boundary engine-payload — override is a zod-validated string, the
48
48
  // user's own tenantId is already TenantId-branded.
49
49
  const tenantId = (override ?? event.user.tenantId);
50
- // With an override the executor user has to move to the target tenant too,
51
- // otherwise the event-store stream lookup runs against event.user.tenantId
52
- // and reports a version conflict although the projection row exists.
53
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
50
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
54
51
  const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
55
52
  const existing = await fetchOne(db, templateResourcesTable, {
56
53
  tenantId,
@@ -87,7 +84,7 @@ export const setWrite = defineWriteHandler({
87
84
  contentFormat: fields.contentFormat,
88
85
  folder: fields.folder,
89
86
  },
90
- }, executorUser, db);
87
+ }, event.user, db, { streamTenantId });
91
88
  if (!result.isSuccess)
92
89
  return result;
93
90
  return {
@@ -95,7 +92,9 @@ export const setWrite = defineWriteHandler({
95
92
  data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
96
93
  };
97
94
  }
98
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
95
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
96
+ streamTenantId,
97
+ });
99
98
  if (!result.isSuccess)
100
99
  return result;
101
100
  return {
@@ -1,5 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { requireForTenant } from "../../shared/index.js";
3
4
  import { templateResourcesTable } from "../table.js";
4
5
  import { executor, upsertPayloadSchema } from "./shared.js";
5
6
  // System-Template anlegen/updaten. Liegt unter SYSTEM_TENANT_ID,
@@ -12,13 +13,10 @@ export const upsertSystemWrite = defineWriteHandler({
12
13
  access: { roles: ["SystemAdmin"] },
13
14
  description: "Creates or overwrites a system-default template under the system tenant with its full variable schema and linked resources, active straight away with no draft stage; SystemAdmin only, since tenants shadow these defaults through upsert-tenant instead.",
14
15
  handler: async (event, ctx) => {
15
- const db = ctx.db;
16
16
  // @cast-boundary engine-payload — SYSTEM_TENANT_ID is a UUID literal,
17
17
  // asserted to the branded TenantId (parseTenantId equivalent).
18
18
  const tenantId = SYSTEM_TENANT_ID;
19
- // The executor user must carry SYSTEM_TENANT as tenantId, otherwise the
20
- // event store looks up the stream under user.tenantId → conflict.
21
- const executorUser = { ...event.user, tenantId };
19
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
22
20
  const existing = await fetchOne(db, templateResourcesTable, {
23
21
  tenantId,
24
22
  slug: event.payload.slug,
@@ -41,7 +39,7 @@ export const upsertSystemWrite = defineWriteHandler({
41
39
  status: "active",
42
40
  };
43
41
  if (existing) {
44
- const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, executorUser, db);
42
+ const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, event.user, db, { streamTenantId });
45
43
  if (!result.isSuccess)
46
44
  return result;
47
45
  return {
@@ -49,7 +47,9 @@ export const upsertSystemWrite = defineWriteHandler({
49
47
  data: { id: String(existing.id), slug: event.payload.slug, isNew: false },
50
48
  };
51
49
  }
52
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
50
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
51
+ streamTenantId,
52
+ });
53
53
  if (!result.isSuccess)
54
54
  return result;
55
55
  // @cast-boundary db-row — executor.create returnt Record-row aus
@@ -2,6 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
5
6
  import { templateResourcesTable } from "../table.js";
6
7
  import { executor, upsertPayloadSchema } from "./shared.js";
7
8
  // Tenant-Override anlegen/updaten. Liegt unter event.user.tenantId,
@@ -18,7 +19,6 @@ export const upsertTenantWrite = defineWriteHandler({
18
19
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
19
20
  description: "Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live; it refuses a system-tenant target, which is what upsert-system is for.",
20
21
  handler: async (event, ctx) => {
21
- const db = ctx.db;
22
22
  const override = event.payload.tenantIdOverride;
23
23
  // upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
24
24
  // scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
@@ -34,7 +34,7 @@ export const upsertTenantWrite = defineWriteHandler({
34
34
  // event.user.tenantId schon TenantId-branded; union als TenantId casten
35
35
  // ist legit (override ist UUID-Format-validiert in schema).
36
36
  const tenantId = (override ?? event.user.tenantId);
37
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
37
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
38
38
  const existing = await fetchOne(db, templateResourcesTable, {
39
39
  tenantId,
40
40
  slug: event.payload.slug,
@@ -56,7 +56,7 @@ export const upsertTenantWrite = defineWriteHandler({
56
56
  status: event.payload.status,
57
57
  };
58
58
  if (existing) {
59
- const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, executorUser, db);
59
+ const result = await executor.update({ id: existing.id, version: existing.version, changes: fields }, event.user, db, { streamTenantId });
60
60
  if (!result.isSuccess)
61
61
  return result;
62
62
  return {
@@ -64,7 +64,9 @@ export const upsertTenantWrite = defineWriteHandler({
64
64
  data: { id: String(existing.id), slug: event.payload.slug, isNew: false },
65
65
  };
66
66
  }
67
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
67
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
68
+ streamTenantId,
69
+ });
68
70
  if (!result.isSuccess)
69
71
  return result;
70
72
  // @cast-boundary db-row — executor.create returnt Record-row aus