@cosmicdrift/kumiko-bundled-features 0.209.1 → 0.211.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 (58) hide show
  1. package/package.json +8 -8
  2. package/src/auth-mfa/schema/user-mfa.ts +8 -5
  3. package/src/auth-mfa-user-data/hooks.ts +9 -8
  4. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
  5. package/src/billing-foundation/entities.ts +5 -3
  6. package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
  7. package/src/compliance-profiles/schema/profile-selection.ts +2 -1
  8. package/src/config/table.ts +4 -1
  9. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
  10. package/src/custom-fields/feature.ts +2 -2
  11. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  12. package/src/data-retention/schema/tenant-retention-override.ts +6 -3
  13. package/src/delivery/tables.ts +1 -1
  14. package/src/document-ingest-foundation/entity.ts +6 -3
  15. package/src/file-derivatives/changes.json +9 -1
  16. package/src/files/README.md +11 -11
  17. package/src/form-draft/entity.ts +6 -3
  18. package/src/inbound-mail-foundation/entities.ts +13 -11
  19. package/src/inbound-mail-foundation/feature.ts +5 -5
  20. package/src/ledger/entity.ts +5 -3
  21. package/src/managed-pages/screens/branding-screen.ts +2 -1
  22. package/src/managed-pages/table.ts +3 -2
  23. package/src/notes-history/entity.ts +8 -4
  24. package/src/notes-history-user-data/hooks.ts +6 -5
  25. package/src/personal-access-tokens/changes.json +7 -0
  26. package/src/personal-access-tokens/schema/api-token.ts +2 -1
  27. package/src/sessions/feature.ts +0 -4
  28. package/src/sessions/schema/user-session.ts +4 -2
  29. package/src/tags/entity.ts +11 -5
  30. package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
  31. package/src/template-resolver/changes.json +9 -1
  32. package/src/template-resolver/table.ts +4 -1
  33. package/src/template-resolver/user-content-table.ts +5 -2
  34. package/src/template-resolver-user-data/hooks.ts +6 -5
  35. package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
  36. package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
  37. package/src/tenant/changes.json +6 -0
  38. package/src/tenant/constants.ts +17 -4
  39. package/src/tenant/feature.ts +28 -11
  40. package/src/tenant/handlers/team-list.query.ts +250 -0
  41. package/src/tenant/i18n.ts +20 -0
  42. package/src/tenant/invitation-table.ts +3 -3
  43. package/src/tenant/screens.ts +111 -3
  44. package/src/tenant/web/client-plugin.tsx +11 -6
  45. package/src/tenant/web/index.ts +2 -2
  46. package/src/tenant/web/member-roles-cell.tsx +12 -0
  47. package/src/tenant/web/member-status-cell.tsx +23 -0
  48. package/src/tenant-lifecycle/boot-checks.ts +2 -2
  49. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
  50. package/src/tenant-settings/feature.ts +12 -0
  51. package/src/user/schema/user.ts +5 -5
  52. package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
  53. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
  54. package/src/user-data-rights/boot-checks.ts +2 -2
  55. package/src/user-data-rights/schema/download-token.ts +12 -10
  56. package/src/user-data-rights/schema/export-job.ts +5 -4
  57. package/src/tenant/web/i18n.ts +0 -24
  58. package/src/tenant/web/members-screen.tsx +0 -270
@@ -96,7 +96,17 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
96
96
 
97
97
  test("V3: pii entity without any EXT_USER_DATA hook → throws the guard's own message", () => {
98
98
  const bad = defineFeature("crm", (r) => {
99
- r.entity("contact", createEntity({ fields: { email: createTextField({ pii: true }) } }));
99
+ r.entity(
100
+ "contact",
101
+ createEntity({
102
+ fields: {
103
+ email: createTextField({
104
+ personal: "self",
105
+ find: "none",
106
+ }),
107
+ },
108
+ }),
109
+ );
100
110
  });
101
111
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
102
112
  });
@@ -110,7 +120,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
110
120
  source: "contact",
111
121
  table: projectionTable,
112
122
  apply: { [contactCreated.name]: async () => {} },
113
- entity: createEntity({ fields: { email: createTextField({ pii: true }) } }),
123
+ entity: createEntity({
124
+ fields: {
125
+ email: createTextField({
126
+ personal: "self",
127
+ find: "none",
128
+ }),
129
+ },
130
+ }),
114
131
  });
115
132
  });
116
133
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
@@ -120,7 +137,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
120
137
  const bad = defineFeature("tasks", (r) => {
121
138
  r.entity(
122
139
  "task",
123
- createEntity({ fields: { assigneeId: createTextField({ subjectRef: true }) } }),
140
+ createEntity({
141
+ fields: {
142
+ assigneeId: createTextField({
143
+ personal: "ref",
144
+ }),
145
+ },
146
+ }),
124
147
  );
125
148
  });
126
149
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
@@ -131,7 +154,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
131
154
  r.requires("user-data-rights");
132
155
  r.entity(
133
156
  "task",
134
- createEntity({ fields: { assigneeId: createTextField({ subjectRef: true }) } }),
157
+ createEntity({
158
+ fields: {
159
+ assigneeId: createTextField({
160
+ personal: "ref",
161
+ }),
162
+ },
163
+ }),
135
164
  );
136
165
  r.useExtension(EXT_USER_DATA, "task", { export: async () => null, delete: async () => {} });
137
166
  });
@@ -148,7 +177,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
148
177
  source: "contact",
149
178
  table: projectionTable,
150
179
  apply: { [contactCreated.name]: async () => {} },
151
- entity: createEntity({ fields: { email: createTextField({ pii: true }) } }),
180
+ entity: createEntity({
181
+ fields: {
182
+ email: createTextField({
183
+ personal: "self",
184
+ find: "none",
185
+ }),
186
+ },
187
+ }),
152
188
  });
153
189
  r.useExtension(EXT_USER_DATA, "contact-summary", {
154
190
  export: async () => null,
@@ -162,7 +198,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
162
198
  const bad = defineFeature("billing", (r) => {
163
199
  r.entity(
164
200
  "subscription",
165
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
201
+ createEntity({
202
+ fields: {
203
+ providerCustomerId: createTextField({
204
+ personal: "tenant",
205
+ find: "none",
206
+ }),
207
+ },
208
+ }),
166
209
  );
167
210
  });
168
211
  expect(() => validateBoot([...minimalTenantLifecycleFeatures(), bad])).toThrow(
@@ -174,7 +217,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
174
217
  const bad = defineFeature("billing", (r) => {
175
218
  r.entity(
176
219
  "subscription",
177
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
220
+ createEntity({
221
+ fields: {
222
+ providerCustomerId: createTextField({
223
+ personal: "tenant",
224
+ find: "none",
225
+ }),
226
+ },
227
+ }),
178
228
  );
179
229
  });
180
230
  const features = minimalTenantLifecycleFeatures().concat(bad);
@@ -186,7 +236,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
186
236
  const notMounted = defineFeature("billing-standalone", (r) => {
187
237
  r.entity(
188
238
  "subscription",
189
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
239
+ createEntity({
240
+ fields: {
241
+ providerCustomerId: createTextField({
242
+ personal: "tenant",
243
+ find: "none",
244
+ }),
245
+ },
246
+ }),
190
247
  );
191
248
  });
192
249
  expect(() => validateBoot([...baseFeatures(), notMounted])).not.toThrow();
@@ -197,7 +254,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
197
254
  r.requires("tenant-lifecycle");
198
255
  r.entity(
199
256
  "subscription",
200
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
257
+ createEntity({
258
+ fields: {
259
+ providerCustomerId: createTextField({
260
+ personal: "tenant",
261
+ find: "none",
262
+ }),
263
+ },
264
+ }),
201
265
  );
202
266
  r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: async () => {} });
203
267
  });
@@ -46,8 +46,8 @@ const noteEntity = createEntity({
46
46
  authorId: createTextField({ required: true }),
47
47
  body: createTextField({
48
48
  required: true,
49
- searchable: true,
50
- userOwned: { ownerField: "authorId" },
49
+ personal: { of: "authorId" },
50
+ find: "fuzzy",
51
51
  }),
52
52
  },
53
53
  });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EXT_USER_DATA,
3
3
  type FeatureDefinition,
4
- type PiiAnnotations,
4
+ type ResolvedPiiFlags,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { entitiesOf } from "../shared";
7
7
 
@@ -58,7 +58,7 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
58
58
  if (hookedEntities.has(entityName)) continue;
59
59
  const subjectFields = Object.entries(entity.fields)
60
60
  .filter(([, field]) => {
61
- const annot = field as PiiAnnotations; // @cast-boundary schema-walk
61
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
62
62
  return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
63
63
  })
64
64
  .map(([name]) => name);
@@ -75,21 +75,23 @@ export const exportDownloadTokenEntity = createEntity({
75
75
  // (z.B. broken Sync-Tool).
76
76
  useCount: createBigIntField({}),
77
77
 
78
- // Audit: Source-IP des letzten Downloads. Hilft DPO bei Untersuchung
79
- // ungewoehnlicher Aktivitaeten ("Token wurde von 5 verschiedenen
80
- // IPs genutzt"). Plain-IP-V4/IPv6, kein hash — DPO braucht direkt
81
- // lesbar. is-business-data weil Token-Audit kein User-PII ist
82
- // (gehoert dem Tenant-Operator).
78
+ // Audit: source IP of the last download. Helps the DPO investigate
79
+ // unusual activity ("token was used from 5 different IPs"). Plain
80
+ // IPv4/IPv6, no hash — DPO needs it directly readable.
81
+ // `reason: "is_business_data"` because token-audit data isn't user
82
+ // PII (it belongs to the tenant operator).
83
83
  lastUsedFromIp: createTextField({
84
- maxLength: 45, // IPv6 max
85
- allowPlaintext: "is-business-data",
84
+ maxLength: 45,
85
+ personal: false,
86
+ reason: "is_business_data",
86
87
  }),
87
88
 
88
- // Audit: User-Agent des letzten Downloads. Audit-Wert (Email-Client
89
- // vs Browser vs CLI-Tool unterscheidbar). is-business-data analog.
89
+ // Audit: user agent of the last download (distinguishes email
90
+ // client vs browser vs CLI tool). Same `reason` as above.
90
91
  lastUsedUserAgent: createTextField({
91
92
  maxLength: 500,
92
- allowPlaintext: "is-business-data",
93
+ personal: false,
94
+ reason: "is_business_data",
93
95
  }),
94
96
  },
95
97
 
@@ -133,11 +133,12 @@ export const exportJobEntity = createEntity({
133
133
  // verbleiben.
134
134
  expiresAt: createTimestampField({}),
135
135
 
136
- // Failed-State Diagnose. longText weil Hook-Errors mit Stack-Trace
137
- // mehrere KB werden koennen. is-business-data weil Job-Status
138
- // public im UI-Polling sichtbar (kein PII).
136
+ // Failed-state diagnostics. longText because hook errors with stack
137
+ // traces can run several KB. `reason: "is_business_data"` because
138
+ // job status is publicly visible in UI polling (no PII).
139
139
  errorMessage: createLongTextField({
140
- allowPlaintext: "is-business-data",
140
+ personal: false,
141
+ reason: "is_business_data",
141
142
  }),
142
143
 
143
144
  // Audit-Info fuer Operator: wie gross war der Export. Hilft beim
@@ -1,24 +0,0 @@
1
- // @runtime client
2
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
-
4
- export const defaultTranslations: TranslationsByLocale = {
5
- en: {
6
- "tenant.members.title": "Members",
7
- "tenant.members.loading": "Loading members…",
8
- "tenant.members.active": "Active members",
9
- "tenant.members.invite.title": "New invitation",
10
- "tenant.members.invite.email": "Email",
11
- "tenant.members.invite.role": "Role",
12
- "tenant.members.invite.submit": "Send invitation",
13
- "tenant.members.invite.submitting": "Sending invitation…",
14
- "tenant.members.invite.success": "Invitation sent to {email}",
15
- "tenant.members.pending": "Pending invitations",
16
- "tenant.members.pending.empty": "No pending invitations.",
17
- "tenant.members.cancel": "Cancel",
18
- "tenant.members.col.userId": "User ID",
19
- "tenant.members.col.roles": "Roles",
20
- "tenant.members.col.email": "Email",
21
- "tenant.members.col.expires": "Valid until",
22
- "tenant.nav.members": "Team",
23
- },
24
- };
@@ -1,270 +0,0 @@
1
- // @runtime client
2
- // MembersScreen — active members + pending invitations for the current tenant.
3
- // Read-only member list; invite + cancel only (no updateMemberRoles — SystemAdmin-only).
4
-
5
- import { AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/constants";
6
- import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
7
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
8
- import { type ReactNode, useCallback, useEffect, useState } from "react";
9
- import { DEFAULT_INVITE_ROLE_OPTIONS, TenantHandlers, TenantQueries } from "../constants";
10
-
11
- type MemberRow = {
12
- readonly id: string;
13
- readonly userId: string;
14
- readonly tenantId: string;
15
- readonly email: string | null;
16
- readonly displayName: string | null;
17
- readonly roles: readonly string[];
18
- };
19
-
20
- type InvitationRow = {
21
- readonly id: string;
22
- readonly email: string;
23
- readonly role: string;
24
- readonly status: string;
25
- readonly expiresAt: string;
26
- };
27
-
28
- type State =
29
- | { readonly kind: "loading" }
30
- | { readonly kind: "error"; readonly message: string }
31
- | {
32
- readonly kind: "ready";
33
- readonly members: readonly MemberRow[];
34
- readonly invitations: readonly InvitationRow[];
35
- };
36
-
37
- type RoleOption = (typeof DEFAULT_INVITE_ROLE_OPTIONS)[number];
38
-
39
- export type MembersScreenProps = {
40
- /** Invite-role allowlist; defaults to User/Admin/Editor — never reserved/global roles. */
41
- readonly inviteRoleOptions?: readonly RoleOption[];
42
- };
43
-
44
- export function MembersScreen({
45
- inviteRoleOptions = DEFAULT_INVITE_ROLE_OPTIONS,
46
- }: MembersScreenProps): ReactNode {
47
- const t = useTranslation();
48
- const { Banner, Button, Card, DataTable, Field, Form, Heading, Input, Text } = usePrimitives();
49
- const dispatcher = useDispatcher();
50
- const [state, setState] = useState<State>({ kind: "loading" });
51
- const [inviteEmail, setInviteEmail] = useState("");
52
- const [inviteRole, setInviteRole] = useState<RoleOption>(inviteRoleOptions[0] ?? "User");
53
- const [submitting, setSubmitting] = useState(false);
54
- const [actionError, setActionError] = useState<string | null>(null);
55
- const [lastInvited, setLastInvited] = useState<string | null>(null);
56
-
57
- const refresh = useCallback(async (): Promise<void> => {
58
- const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
59
- if (!membersRes.isSuccess) {
60
- setState({ kind: "error", message: membersRes.error.message });
61
- return;
62
- }
63
- const invitationsRes = await dispatcher.query<readonly InvitationRow[]>(
64
- TenantQueries.invitations,
65
- {},
66
- );
67
- if (!invitationsRes.isSuccess) {
68
- setState({ kind: "error", message: invitationsRes.error.message });
69
- return;
70
- }
71
- setState({ kind: "ready", members: membersRes.data, invitations: invitationsRes.data });
72
- }, [dispatcher]);
73
-
74
- useEffect(() => {
75
- void refresh();
76
- }, [refresh]);
77
-
78
- const onInvite = (e?: React.FormEvent): void => {
79
- e?.preventDefault();
80
- void (async (): Promise<void> => {
81
- setActionError(null);
82
- setSubmitting(true);
83
- const email = inviteEmail.trim();
84
- const res = await dispatcher.write(AuthHandlers.inviteCreate, { email, role: inviteRole });
85
- setSubmitting(false);
86
- if (!res.isSuccess) {
87
- setActionError(res.error.message);
88
- return;
89
- }
90
- setLastInvited(email);
91
- setInviteEmail("");
92
- await refresh();
93
- })();
94
- };
95
-
96
- const onCancel = async (invitationId: string): Promise<void> => {
97
- setActionError(null);
98
- const res = await dispatcher.write(TenantHandlers.cancelInvitation, { invitationId });
99
- if (!res.isSuccess) {
100
- setActionError(res.error.message);
101
- return;
102
- }
103
- await refresh();
104
- };
105
-
106
- if (state.kind === "loading") {
107
- return (
108
- <FormScreenShell testId="members-screen">
109
- <Text variant="small">{t("tenant.members.loading")}</Text>
110
- </FormScreenShell>
111
- );
112
- }
113
-
114
- if (state.kind === "error") {
115
- return (
116
- <FormScreenShell testId="members-screen">
117
- <Banner variant="error">{state.message}</Banner>
118
- </FormScreenShell>
119
- );
120
- }
121
-
122
- const roleSelectOptions = inviteRoleOptions.map((r) => ({ value: r, label: r }));
123
-
124
- return (
125
- <FormScreenShell testId="members-screen" className="flex flex-col gap-6">
126
- <Heading variant="page">{t("tenant.members.title")}</Heading>
127
-
128
- <Card
129
- slots={{ title: `${t("tenant.members.active")} (${state.members.length})` }}
130
- options={{ padded: false }}
131
- >
132
- <DataTable
133
- testId="members-active-table"
134
- columns={[
135
- {
136
- field: "email",
137
- label: t("tenant.members.col.email"),
138
- type: "string",
139
- sortable: false,
140
- },
141
- {
142
- field: "roles",
143
- label: t("tenant.members.col.roles"),
144
- type: "string",
145
- sortable: false,
146
- },
147
- ]}
148
- rows={state.members.map((m) => ({
149
- id: m.id,
150
- values: {
151
- email: m.email ?? `${m.userId.slice(0, 8)}…`,
152
- roles: m.roles.join(", "),
153
- },
154
- }))}
155
- />
156
- </Card>
157
-
158
- <Form
159
- testId="invite-form"
160
- title={t("tenant.members.invite.title")}
161
- onSubmit={onInvite}
162
- actions={
163
- <Button
164
- type="submit"
165
- variant="primary"
166
- loading={submitting}
167
- disabled={submitting}
168
- testId="invite-submit"
169
- >
170
- {submitting ? t("tenant.members.invite.submitting") : t("tenant.members.invite.submit")}
171
- </Button>
172
- }
173
- >
174
- <div className="grid gap-4 sm:grid-cols-2">
175
- <Field id="invite-email" label={t("tenant.members.invite.email")} required>
176
- <Input
177
- kind="email"
178
- id="invite-email"
179
- name="invite-email"
180
- value={inviteEmail}
181
- onChange={setInviteEmail}
182
- disabled={submitting}
183
- required
184
- autoComplete="email"
185
- />
186
- </Field>
187
- <Field id="invite-role" label={t("tenant.members.invite.role")} required>
188
- <Input
189
- kind="select"
190
- id="invite-role"
191
- name="invite-role"
192
- value={inviteRole}
193
- onChange={(v) => setInviteRole(v as RoleOption)}
194
- options={roleSelectOptions}
195
- disabled={submitting}
196
- required
197
- />
198
- </Field>
199
- </div>
200
- {lastInvited !== null && !submitting && (
201
- <Banner variant="info">
202
- {t("tenant.members.invite.success", { email: lastInvited })}
203
- </Banner>
204
- )}
205
- {actionError !== null && <Banner variant="error">{actionError}</Banner>}
206
- </Form>
207
-
208
- <Card
209
- slots={{ title: `${t("tenant.members.pending")} (${state.invitations.length})` }}
210
- options={{ padded: false }}
211
- >
212
- <DataTable
213
- testId="pending-list"
214
- columns={[
215
- {
216
- field: "email",
217
- label: t("tenant.members.col.email"),
218
- type: "string",
219
- sortable: false,
220
- },
221
- {
222
- field: "role",
223
- label: t("tenant.members.col.roles"),
224
- type: "string",
225
- sortable: false,
226
- },
227
- {
228
- field: "expires",
229
- label: t("tenant.members.col.expires"),
230
- type: "string",
231
- sortable: false,
232
- },
233
- ]}
234
- rows={state.invitations.map((inv) => ({
235
- id: inv.id,
236
- values: {
237
- email: inv.email,
238
- role: inv.role,
239
- expires: formatExpiresAt(inv.expiresAt),
240
- },
241
- }))}
242
- rowActions={[
243
- {
244
- id: "cancel",
245
- label: t("tenant.members.cancel"),
246
- style: "danger",
247
- onTrigger: (row) => void onCancel(row.id),
248
- },
249
- ]}
250
- rowActionMode="inline"
251
- emptyState={<Text variant="small">{t("tenant.members.pending.empty")}</Text>}
252
- />
253
- </Card>
254
- </FormScreenShell>
255
- );
256
- }
257
-
258
- function formatExpiresAt(iso: string): string {
259
- try {
260
- return new Date(iso).toLocaleString(undefined, {
261
- day: "2-digit",
262
- month: "2-digit",
263
- year: "numeric",
264
- hour: "2-digit",
265
- minute: "2-digit",
266
- });
267
- } catch {
268
- return iso;
269
- }
270
- }