@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 (121) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
  4. package/src/audit/__tests__/constants.test.ts +8 -0
  5. package/src/audit/web/audit-log-detail-screen.tsx +30 -14
  6. package/src/audit/web/audit-log-screen.tsx +21 -7
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  8. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  9. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  10. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  11. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  12. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  13. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  14. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  15. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  16. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  17. package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  19. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  20. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  21. package/src/auth-email-password/i18n.ts +1 -0
  22. package/src/auth-email-password/magic-link-mail.ts +17 -1
  23. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  24. package/src/auth-email-password/web/auth-client.ts +1 -7
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  26. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  27. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  28. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  29. package/src/custom-fields/constants.ts +4 -3
  30. package/src/custom-fields/feature.ts +7 -8
  31. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  32. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  33. package/src/delivery/db/queries/preferences.ts +3 -2
  34. package/src/delivery/handlers/log.query.ts +1 -0
  35. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
  36. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
  37. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
  38. package/src/feature-toggles/feature.ts +24 -4
  39. package/src/feature-toggles/handlers/registered.query.ts +27 -16
  40. package/src/feature-toggles/i18n.ts +5 -0
  41. package/src/feature-toggles/web/client-plugin.tsx +1 -5
  42. package/src/feature-toggles/web/index.ts +0 -1
  43. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  44. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  45. package/src/file-derivatives/feature.ts +7 -6
  46. package/src/file-provider-s3/feature.ts +1 -2
  47. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  48. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  49. package/src/form-draft/handlers/discard.write.ts +8 -8
  50. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  51. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  52. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  53. package/src/jobs/feature.ts +7 -2
  54. package/src/jobs/handlers/detail.query.ts +22 -11
  55. package/src/jobs/handlers/list.query.ts +17 -6
  56. package/src/jobs/job-run-logger.ts +78 -36
  57. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  58. package/src/mail-transport-smtp/feature.ts +1 -2
  59. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  60. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  61. package/src/notes-history/handlers/add-note.write.ts +7 -3
  62. package/src/notes-history/web/notes-section.tsx +10 -2
  63. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  64. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  65. package/src/secrets/feature.ts +9 -2
  66. package/src/secrets/handlers/delete.write.ts +0 -4
  67. package/src/secrets/handlers/list.query.ts +0 -4
  68. package/src/secrets/handlers/set.write.ts +0 -4
  69. package/src/secrets/index.ts +3 -3
  70. package/src/shared/index.ts +1 -0
  71. package/src/shared/map-with-concurrency.test.ts +2 -2
  72. package/src/shared/session-field.ts +10 -0
  73. package/src/shared/session-locale-field.test.ts +9 -0
  74. package/src/shared/session-locale-field.ts +4 -1
  75. package/src/shared/session-timezone-field.ts +2 -1
  76. package/src/subscription-stripe/feature.ts +5 -11
  77. package/src/tags/__tests__/feature.test.ts +18 -5
  78. package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
  79. package/src/tags/constants.ts +3 -2
  80. package/src/tags/entity.ts +2 -0
  81. package/src/tags/feature.ts +20 -11
  82. package/src/tags/handlers/create-tag.write.ts +5 -2
  83. package/src/tags/handlers/delete-tag.write.ts +5 -2
  84. package/src/tags/handlers/update-tag.write.ts +5 -2
  85. package/src/tags/i18n.ts +4 -0
  86. package/src/tags/index.ts +2 -0
  87. package/src/tags/screens.ts +66 -0
  88. package/src/tags/web/client-plugin.tsx +1 -6
  89. package/src/tags/web/index.ts +1 -0
  90. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  91. package/src/template-resolver/seeding.ts +78 -6
  92. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  93. package/src/template-resolver/web/client-plugin.tsx +2 -1
  94. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  95. package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
  96. package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
  97. package/src/tenant/handlers/members.query.ts +6 -1
  98. package/src/tenant/handlers/team-list.query.ts +3 -1
  99. package/src/tenant/handlers/update-member-roles.write.ts +16 -2
  100. package/src/tenant/i18n.ts +2 -3
  101. package/src/tenant/screens.ts +3 -2
  102. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  103. package/src/tenant-settings/feature.ts +6 -30
  104. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  105. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  106. package/src/user/__tests__/user.integration.test.ts +156 -118
  107. package/src/user/handlers/create.write.ts +5 -4
  108. package/src/user/handlers/detail.query.ts +24 -2
  109. package/src/user/handlers/list.query.ts +19 -26
  110. package/src/user/handlers/update-roles.ts +23 -6
  111. package/src/user/handlers/update.write.ts +6 -5
  112. package/src/user/i18n.ts +0 -6
  113. package/src/user/roles-input-schema.ts +20 -0
  114. package/src/user/schema/user.ts +2 -3
  115. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  116. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  117. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  118. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  119. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  120. package/src/user-profile/web/profile-screen.tsx +1 -1
  121. package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
@@ -0,0 +1,38 @@
1
+ import { beforeAll, describe, expect, test } from "bun:test";
2
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
3
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
4
+
5
+ describe("dispatchMagicLinkMail appUrl locale negotiation", () => {
6
+ beforeAll(() => {
7
+ registerMailTranslations("de", { "test.unused": "x" });
8
+ });
9
+
10
+ test("negotiates de-AT down to de for language-in-path appUrl", async () => {
11
+ const seen: string[] = [];
12
+ await dispatchMagicLinkMail(
13
+ async () => {},
14
+ {
15
+ handlerName: "test",
16
+ notificationType: "test.mail",
17
+ renderContent: (args) => {
18
+ seen.push(args.url);
19
+ return {
20
+ subject: "s",
21
+ header: "h",
22
+ sections: [],
23
+ footer: "f",
24
+ };
25
+ },
26
+ },
27
+ {
28
+ email: "a@example.com",
29
+ appUrl: (locale) => `https://app.example.com/${locale}/activate`,
30
+ token: "tok",
31
+ expiresAt: "2099-01-01T00:00:00Z",
32
+ locale: "de-AT",
33
+ },
34
+ );
35
+ expect(seen[0]).toContain("https://app.example.com/de/activate");
36
+ expect(seen[0]).not.toContain("/de-AT/");
37
+ });
38
+ });
@@ -173,7 +173,7 @@ describe("multi-roles: privilege-escalation blocked via field-level write-access
173
173
  {
174
174
  id: targetId,
175
175
  version: 1,
176
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
176
+ changes: { roles: ["SystemAdmin"] },
177
177
  },
178
178
  tenantAdminSession,
179
179
  );
@@ -201,7 +201,7 @@ describe("multi-roles: privilege-escalation blocked via field-level write-access
201
201
  {
202
202
  id: userId,
203
203
  version: 1,
204
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
204
+ changes: { roles: ["SystemAdmin"] },
205
205
  },
206
206
  userSession,
207
207
  );
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
+ import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
4
5
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
5
6
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
@@ -41,11 +42,6 @@ import { signResetToken } from "../reset-token";
41
42
  // directly (no jobRunner in the test stack → inline send).
42
43
  const emailTransport = createInMemoryTransport();
43
44
 
44
- // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
45
- // constant is a framework-internal implementation detail, not exported from
46
- // the public /api barrel (same as TENANT_HEADER_NAME).
47
- const LOCALE_HEADER_NAME = "X-Locale";
48
-
49
45
  registerMailTranslations("de", localeDeBundle);
50
46
 
51
47
  // Records the userId every time the sessions feature's auto-revoke hook
@@ -26,6 +26,7 @@
26
26
  // zusammen).
27
27
 
28
28
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
29
+ import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
29
30
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
30
31
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
31
32
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
@@ -98,11 +99,6 @@ const tenantMembershipHookVendorFeature = defineFeature(
98
99
 
99
100
  const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
100
101
 
101
- // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
102
- // constant is a framework-internal implementation detail, not exported from
103
- // the public /api barrel (same as TENANT_HEADER_NAME).
104
- const LOCALE_HEADER_NAME = "X-Locale";
105
-
106
102
  registerMailTranslations("de", localeDeBundle);
107
103
 
108
104
  // Activation mails now go through delivery (ctx.notify → channel-email). The
@@ -39,6 +39,11 @@ import {
39
39
  tenantInvitationEntity,
40
40
  tenantInvitationsTable,
41
41
  } from "../../tenant/invitation-table";
42
+ // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
43
+ import {
44
+ findForbiddenMembershipRole,
45
+ reservedMembershipRoleError,
46
+ } from "../../tenant/membership-roles";
42
47
  // kumiko-lint-ignore cross-feature-import membership-seed-helper for a privileged cross-tenant add
43
48
  import { seedTenantMembership } from "../../tenant/seeding";
44
49
  // kumiko-lint-ignore cross-feature-import login-style password-check
@@ -204,6 +209,10 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
204
209
  const dbConn = ctx.db.raw;
205
210
 
206
211
  if (!alreadyMember) {
212
+ const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
213
+ if (forbiddenInviteRole !== undefined) {
214
+ return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
215
+ }
207
216
  await seedTenantMembership(dbConn, {
208
217
  userId,
209
218
  tenantId: invitationTenantId,
@@ -31,6 +31,11 @@ import {
31
31
  tenantInvitationEntity,
32
32
  tenantInvitationsTable,
33
33
  } from "../../tenant/invitation-table";
34
+ // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
35
+ import {
36
+ findForbiddenMembershipRole,
37
+ reservedMembershipRoleError,
38
+ } from "../../tenant/membership-roles";
34
39
  // kumiko-lint-ignore cross-feature-import direkter membership-Lookup (ungefiltert, s. alreadyMember-Kommentar)
35
40
  import { tenantMembershipsTable } from "../../tenant/membership-table";
36
41
  // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
@@ -70,6 +75,7 @@ export function createInviteAcceptHandler() {
70
75
  // nutzen `roles: ["all"]` weil dort GUEST_USER mit ["all"]-role
71
76
  // dispatched wird.
72
77
  access: { openToAll: true },
78
+ // kumiko-lint-ignore complexity-budget invite branches (auth/anon/burn) stay in one handler
73
79
  handler: async (event, ctx) => {
74
80
  if (!ctx.redis) {
75
81
  return writeFailure(
@@ -142,6 +148,10 @@ export function createInviteAcceptHandler() {
142
148
  // ["SystemAdmin"]-Role akzeptiert; createSystemUser produziert
143
149
  // "system"-Role die NICHT matcht. Direkt-via-Executor bypassed
144
150
  // den Access-Check für privilegierte Cross-Tenant-Operationen.
151
+ const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
152
+ if (forbiddenInviteRole !== undefined) {
153
+ return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
154
+ }
145
155
  await seedTenantMembership(dbConn, {
146
156
  userId: event.user.id,
147
157
  tenantId: invitationTenantId,
@@ -17,7 +17,7 @@
17
17
  // erlaubt new-user-signup mit dem Token. Keine Enumeration durchs
18
18
  // invite-create.
19
19
 
20
- import { generateToken, requestContext } from "@cosmicdrift/kumiko-framework/api";
20
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
21
21
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
22
22
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
23
23
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
@@ -41,7 +41,7 @@ import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
41
41
  import type { AuthMailLocale } from "../email-templates";
42
42
  import { renderInviteEmail } from "../email-templates";
43
43
  import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store";
44
- import { dispatchMagicLinkMail } from "../magic-link-mail";
44
+ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
45
45
 
46
46
  const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
47
47
 
@@ -103,9 +103,11 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
103
103
  return writeFailure(reservedMembershipRoleError(forbiddenRole));
104
104
  }
105
105
 
106
- const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [
107
- event.payload.role,
108
- ]);
106
+ const elevationForbidden = findForbiddenRoleAssignment(
107
+ event.user.roles,
108
+ [event.payload.role],
109
+ [],
110
+ );
109
111
  if (elevationForbidden !== undefined) {
110
112
  return writeFailure(unassignableMembershipRoleError(elevationForbidden));
111
113
  }
@@ -168,10 +170,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
168
170
 
169
171
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
170
172
 
171
- // Same precedence as signup/reset/verify: the inviting admin's active
172
- // browser language wins when the request carries one, else the
173
- // handler's static opts.locale, else ctx.locale's own default.
174
- const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
173
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
175
174
 
176
175
  await dispatchMagicLinkMail(
177
176
  ctx.notify,
@@ -39,6 +39,11 @@ import {
39
39
  tenantInvitationEntity,
40
40
  tenantInvitationsTable,
41
41
  } from "../../tenant/invitation-table";
42
+ // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
43
+ import {
44
+ findForbiddenMembershipRole,
45
+ reservedMembershipRoleError,
46
+ } from "../../tenant/membership-roles";
42
47
  // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
43
48
  import { seedTenantMembership } from "../../tenant/seeding";
44
49
  // kumiko-lint-ignore cross-feature-import existence-check
@@ -143,6 +148,10 @@ export function createInviteSignupCompleteHandler() {
143
148
  // Membership-Add via seed-helper (gleiches Pattern wie
144
149
  // provisionSignupAccount — bypassed addMember access-check
145
150
  // weil createSystemUser nicht ["SystemAdmin"] matcht).
151
+ const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
152
+ if (forbiddenInviteRole !== undefined) {
153
+ return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
154
+ }
146
155
  await seedTenantMembership(dbConn, {
147
156
  userId,
148
157
  tenantId: invitationTenantId,
@@ -21,7 +21,7 @@
21
21
  // suppressing the link on the request side would be defense-in-depth, but
22
22
  // with an enumeration risk of its own (separate concern).
23
23
 
24
- import { generateToken, requestContext } from "@cosmicdrift/kumiko-framework/api";
24
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
25
25
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import { Temporal } from "temporal-polyfill";
@@ -29,7 +29,7 @@ import { z } from "zod";
29
29
  import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
30
30
  import type { AuthMailLocale } from "../email-templates";
31
31
  import { renderActivationEmail } from "../email-templates";
32
- import { dispatchMagicLinkMail } from "../magic-link-mail";
32
+ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
33
33
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
34
34
  import {
35
35
  invalidateExistingSignupToken,
@@ -133,15 +133,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
133
133
  // get the same format.
134
134
  const normalizedEmail = normalizeEmail(email);
135
135
 
136
- // ctx.locale always resolves to something (falls back to the app's
137
- // boot default, then "en" — dispatch-shared.ts), so it can't tell us
138
- // whether THIS request actually carried a locale signal. Read the raw
139
- // request-layer value instead: present → the browser's active
140
- // language wins over opts.locale (this handler's static config);
141
- // absent → opts.locale is the real, still-relevant fallback
142
- // (backwards compatibility for callers that configured it and never
143
- // send X-Locale), then ctx.locale's own resolved default.
144
- const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
136
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
145
137
 
146
138
  await dispatchMagicLinkMail(
147
139
  ctx.notify,
@@ -12,7 +12,6 @@
12
12
  // default TTL, extra skip condition) + two error codes — encoded on the
13
13
  // spec rather than duplicated across two near-identical handler bodies.
14
14
 
15
- import { requestContext } from "@cosmicdrift/kumiko-framework/api";
16
15
  import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
17
16
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
18
17
  import type { Temporal } from "temporal-polyfill";
@@ -20,7 +19,7 @@ import { z } from "zod";
20
19
  import { UserQueries } from "../../user";
21
20
  import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
22
21
  import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "../email-templates";
23
- import { dispatchMagicLinkMail } from "../magic-link-mail";
22
+ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
24
23
 
25
24
  const RequestTokenSchema = z.object({
26
25
  email: z.email(),
@@ -124,14 +123,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
124
123
 
125
124
  const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
126
125
 
127
- // ctx.locale always resolves to something (falls back to the app's
128
- // boot default, then "en"), so it can't tell us whether THIS request
129
- // actually carried a locale signal. Read the raw request-layer value
130
- // instead: present → the requester's active language wins over
131
- // opts.locale (this handler's static config); absent → opts.locale
132
- // is the real, still-relevant fallback, then ctx.locale's own
133
- // resolved default.
134
- const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
126
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
135
127
 
136
128
  await dispatchMagicLinkMail(
137
129
  ctx.notify,
@@ -115,6 +115,7 @@ export const defaultTranslations: TranslationsByLocale = {
115
115
  "auth.signupComplete.submitting": "…",
116
116
  "auth.signupComplete.missingToken":
117
117
  "Activation link is missing a token. Please request a new one.",
118
+ "auth.signupComplete.activatedTitle": "Account activated",
118
119
  "auth.signupComplete.activated": "Your account is active and you're signed in.",
119
120
  "auth.signupComplete.continue": "Continue",
120
121
  "auth.inviteAccept.title": "Accept invitation",
@@ -6,8 +6,10 @@
6
6
  // non-unsubscribable). No r.notification is declared; buildMessage falls back
7
7
  // to the structured `data` for the renderer.
8
8
 
9
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
9
10
  import type { NotifyFn } from "@cosmicdrift/kumiko-framework/engine";
10
11
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
11
13
  import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "./email-templates";
12
14
 
13
15
  // Per-flow constants: which notification type to dispatch and how to render the
@@ -43,6 +45,17 @@ function appendToken(appUrl: string, token: string): string {
43
45
  return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
44
46
  }
45
47
 
48
+ // X-Locale on the request wins over the handler's static opts.locale, then
49
+ // ctx.locale's resolved default (always set — boot default / "en"). Reading
50
+ // requestContext is load-bearing: ctx.locale alone cannot tell whether THIS
51
+ // request carried a locale signal.
52
+ export function resolveHandlerMailLocale(
53
+ ctx: { readonly locale?: string },
54
+ optsLocale: string | undefined,
55
+ ): string | undefined {
56
+ return requestContext.get()?.locale ?? optsLocale ?? ctx.locale;
57
+ }
58
+
46
59
  export async function dispatchMagicLinkMail(
47
60
  notify: NotifyFn | undefined,
48
61
  spec: MagicLinkMailSpec,
@@ -57,8 +70,11 @@ export async function dispatchMagicLinkMail(
57
70
  });
58
71
  }
59
72
  const locale = params.locale ?? "en";
73
+ // mailT already root-falls-back; appUrl must use the same negotiated tag
74
+ // or language-in-path apps 404 on e.g. /de-AT/activate.
75
+ const urlLocale = resolveMailLocale(locale);
60
76
  const content = spec.renderContent({
61
- url: appendToken(resolveAppUrl(params.appUrl, locale), params.token),
77
+ url: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
62
78
  expiresAt: params.expiresAt,
63
79
  ...(params.locale !== undefined && { locale: params.locale }),
64
80
  ...(params.appName !== undefined && { appName: params.appName }),
@@ -100,7 +100,7 @@ describe("SignupCompleteScreen", () => {
100
100
 
101
101
  // Continue button targets exactly what resolveLoggedInHref computes from
102
102
  // the response's tenantKey, via the app-supplied function-form prop.
103
- const continueLink = screen.getByRole("link", { name: "Continue" }) as HTMLAnchorElement;
103
+ const continueLink = screen.getByRole("link", { name: "Continue" });
104
104
  expect(continueLink.getAttribute("href")).toBe("/acme/");
105
105
  } finally {
106
106
  window.location.assign = assignOrig;
@@ -411,12 +411,6 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
411
411
  email: body.data.email,
412
412
  displayName: body.data.displayName,
413
413
  ...(body.data.locale !== undefined && { locale: body.data.locale }),
414
- globalRoles: parseGlobalRoles(body.data.roles),
414
+ globalRoles: parseRoles(body.data.roles),
415
415
  };
416
416
  }
417
-
418
- // Defensive parse — roles may be jsonb string[] (0.217+) or a legacy
419
- // JSON-encoded string; malformed → [] so SessionProvider mount never throws.
420
- function parseGlobalRoles(raw: unknown): readonly string[] {
421
- return parseRoles(raw);
422
- }
@@ -23,17 +23,11 @@ import { AuthCard, useUrlToken } from "./auth-form-primitives";
23
23
 
24
24
  export type SignupCompleteScreenProps = {
25
25
  readonly title?: string;
26
- /** Override für den Token aus der URL — Apps die per server-side-
27
- * Render einen Token reinreichen, brauchen das. Default: parsed aus
28
- * `?token=...` in der URL. */
26
+ /** Override for the URL token — server-rendered apps can pass it. Default: `?token=...`. */
29
27
  readonly token?: string;
30
- /** Where to send the user after successful activation. Default "/" —
31
- * Apps mit Multi-Tenant-Routing ersetzen das durch
32
- * `(data) => "/" + data.tenantKey + "/"`. Function-form, weil nur
33
- * nach success bekannt welcher tenantKey zugeteilt wurde. */
28
+ /** Where to send the user after activation. Function-form receives the tenantKey. Default "/". */
34
29
  readonly loggedInHref?: string | ((args: { tenantKey: string }) => string);
35
- /** href für "Schon einen Account?"-Link bei missing-token-Fall.
36
- * Default "/login". */
30
+ /** Href for the "already have an account?" link when the token is missing. Default "/login". */
37
31
  readonly loginHref?: string;
38
32
  };
39
33
 
@@ -89,7 +83,11 @@ export function SignupCompleteScreen({
89
83
  void doSubmit();
90
84
  };
91
85
 
92
- const effectiveTitle = title ?? t("auth.signupComplete.title");
86
+ const effectiveTitle =
87
+ title ??
88
+ (continueHref !== null
89
+ ? t("auth.signupComplete.activatedTitle")
90
+ : t("auth.signupComplete.title"));
93
91
 
94
92
  // Kein Token in der URL → klare Message statt Form ohne Token
95
93
  // (würde nur invalidSignupToken zeigen, verwirrend).
@@ -110,7 +108,9 @@ export function SignupCompleteScreen({
110
108
  return (
111
109
  <AuthCard title={effectiveTitle}>
112
110
  <div className="p-6 pt-0 flex flex-col gap-4">
113
- <p className="text-sm text-muted-foreground">{t("auth.signupComplete.activated")}</p>
111
+ <p className="text-sm text-muted-foreground" role="status">
112
+ {t("auth.signupComplete.activated")}
113
+ </p>
114
114
  <Link href={continueHref} variant="button">
115
115
  {t("auth.signupComplete.continue")}
116
116
  </Link>
@@ -1,10 +1,3 @@
1
- // #1163: config:query:readiness read config rows via asRawClient(db).unsafe()
2
- // directly, bypassing the #1358 closed-connection retry that only covered
3
- // bun-db/query.ts's own selectMany/countWhere. Routed through the exported
4
- // unsafeReadRetrying helper instead — this test mirrors
5
- // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
6
- // the retry now actually fires for this call site.
7
-
8
1
  import { describe, expect, test } from "bun:test";
9
2
  import { selectConfigRowsForKeys, selectConfigRowsForScope } from "../resolver";
10
3
 
@@ -28,9 +21,7 @@ function fakeClient(failures: Error[]): FakeClient {
28
21
  if (err) throw err;
29
22
  return [{ id: "r1", key: "k", value: "v", tenantId: "t1", userId: null }];
30
23
  },
31
- // Top-level pool client — matches what dispatch-query.ts hands buildHandlerContext
32
- // for a standalone query.execute() call (no tx passed, resolveDbSource falls back
33
- // to the pool connection, which has .begin()).
24
+ // begin() present => pool client => retry path
34
25
  begin: () => {
35
26
  throw new Error("not used in test");
36
27
  },
@@ -61,4 +52,21 @@ describe("config db/queries/resolver — closed-connection retry (#1163)", () =>
61
52
  );
62
53
  expect(db.calls).toBe(2);
63
54
  });
55
+
56
+ test("does not retry when the client has no begin()", async () => {
57
+ let calls = 0;
58
+ const db = {
59
+ unsafe: async () => {
60
+ calls++;
61
+ throw closedConnectionError();
62
+ },
63
+ savepoint: () => {
64
+ throw new Error("not used");
65
+ },
66
+ };
67
+ await expect(selectConfigRowsForScope(db as never, "system", "t1", "u1")).rejects.toThrow(
68
+ "connection was closed",
69
+ );
70
+ expect(calls).toBe(1);
71
+ });
64
72
  });
@@ -19,9 +19,11 @@ import {
19
19
  createEntity,
20
20
  createTextField,
21
21
  defineFeature,
22
+ SYSTEM_TENANT_ID,
22
23
  type TenantId,
23
24
  } from "@cosmicdrift/kumiko-framework/engine";
24
25
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
26
+ import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
25
27
  import {
26
28
  setupTestStack,
27
29
  type TestStack,
@@ -158,6 +160,7 @@ describe("crypto-shredding :: forget-subject", () => {
158
160
  TestUsers.systemAdmin,
159
161
  );
160
162
  expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
163
+ await expect(kms.getKey(subject)).rejects.toThrow("Subject key erased");
161
164
  });
162
165
 
163
166
  test("repeat forget: erase is a no-op but each attempt is audited", async () => {
@@ -170,6 +173,25 @@ describe("crypto-shredding :: forget-subject", () => {
170
173
  expect(await forgottenEvents()).toHaveLength(2);
171
174
  });
172
175
 
176
+ // mh#349 continued: resolveTenantScopeDenial's `!features.has("tenant")`
177
+ // branch is a deliberate fail-open for single-/no-tenant apps — this
178
+ // block's stack (top of file) only mounts crypto-shredding, so there is no
179
+ // membership table to check a "user" subject against. Every "user"-kind
180
+ // test above already exercises this path implicitly; this test names and
181
+ // locks the behavior explicitly so a future refactor that tries to add a
182
+ // membership check here doesn't silently start 500ing/denying instead.
183
+ test("fail-open: user subject forget allowed when tenant feature isn't mounted", async () => {
184
+ const subject = { kind: "user", userId: TARGET_USER_ID } as const;
185
+ await kms.createKey(subject);
186
+
187
+ const result = await stack.http.writeOk<{ subjectKey: string }>(
188
+ FORGET,
189
+ { subject, reason: REASON },
190
+ dpoUser,
191
+ );
192
+ expect(result.subjectKey).toBe(`user:${TARGET_USER_ID}`);
193
+ });
194
+
173
195
  test("no KMS configured → 500 with boot hint", async () => {
174
196
  resetPiiSubjectKmsForTests();
175
197
 
@@ -489,6 +511,9 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
489
511
  dpoUser,
490
512
  );
491
513
  expect(result.subjectKey).toBe(`user:${subjectId}`);
514
+ await expect(kms.getKey({ kind: "user", userId: subjectId })).rejects.toThrow(
515
+ "Subject key erased",
516
+ );
492
517
  });
493
518
 
494
519
  test("DPO cannot forget another tenant's self-owned PII subject → 403", async () => {
@@ -516,3 +541,143 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
516
541
  expect(err.httpStatus).toBe(403);
517
542
  });
518
543
  });
544
+
545
+ // mh#349 continued: subjectRowExistsInTenant prefills via to_regclass and
546
+ // skips missing tables (fail-closed deny, not 500). Unexpected query errors
547
+ // propagate. Deliberately never creates this stack's probe table so the
548
+ // existence probe returns null and that entity is skipped.
549
+ describe("crypto-shredding :: forget-subject subjectRowExistsInTenant missing table", () => {
550
+ const missingTableProbeEntity = createEntity({
551
+ table: "read_forget_subject_missing_table_probe",
552
+ fields: {
553
+ recipientName: createTextField({
554
+ required: true,
555
+ maxLength: 100,
556
+ personal: "self",
557
+ find: "exact",
558
+ }),
559
+ },
560
+ });
561
+ const missingTableProbeFeature = defineFeature("forget-subject-missing-table-probe", (r) => {
562
+ r.entity("probe", missingTableProbeEntity);
563
+ });
564
+
565
+ let missingTableStack: TestStack;
566
+ let missingTableKms: InMemoryKmsAdapter;
567
+
568
+ beforeAll(async () => {
569
+ missingTableStack = await setupTestStack({
570
+ features: [
571
+ createCryptoShreddingFeature(),
572
+ createConfigFeature(),
573
+ createTenantFeature(),
574
+ missingTableProbeFeature,
575
+ ],
576
+ });
577
+ await unsafePushTables(missingTableStack.db, { tenantMembershipsTable });
578
+ await createEventsTable(missingTableStack.db);
579
+ // No unsafeCreateEntityTable for missingTableProbeEntity — that's the point.
580
+ });
581
+
582
+ afterAll(async () => {
583
+ await missingTableStack.cleanup();
584
+ });
585
+
586
+ beforeEach(() => {
587
+ missingTableKms = new InMemoryKmsAdapter();
588
+ configurePiiSubjectKms(missingTableKms);
589
+ });
590
+
591
+ afterEach(() => {
592
+ resetPiiSubjectKmsForTests();
593
+ });
594
+
595
+ test("missing self-PII probe table denies (403), doesn't 500", async () => {
596
+ const err = await missingTableStack.http.writeErr(
597
+ FORGET,
598
+ { subject: { kind: "user", userId: TARGET_USER_ID }, reason: REASON },
599
+ dpoUser,
600
+ );
601
+ expect(err.httpStatus).toBe(403);
602
+ });
603
+ });
604
+
605
+ // PR#2412: user.email is `personal: "self"` (→ pii: true) + `searchable: true`,
606
+ // so it is decrypted into the derived search index by design (screens.ts:30
607
+ // documents that the *stored* column is ciphertext, not that search never
608
+ // sees plaintext). The guarantee that actually protects that comment is
609
+ // crypto-shredding purging the search doc on erasure — regression-lock it
610
+ // against the real production field (not just the framework's synthetic pii
611
+ // probe in reindex-entity.integration.test.ts), including across a search
612
+ // index rebuild.
613
+ describe("crypto-shredding :: forget-subject purges user.email from the search index", () => {
614
+ let emailSearchStack: TestStack;
615
+ let emailSearchKms: InMemoryKmsAdapter;
616
+
617
+ beforeAll(async () => {
618
+ emailSearchStack = await setupTestStack({
619
+ features: [createCryptoShreddingFeature(), createUserFeature()],
620
+ });
621
+ await unsafeCreateEntityTable(emailSearchStack.db, userEntity);
622
+ await createEventsTable(emailSearchStack.db);
623
+ });
624
+
625
+ afterAll(async () => {
626
+ await emailSearchStack.cleanup();
627
+ });
628
+
629
+ beforeEach(async () => {
630
+ await resetTestTables(emailSearchStack.db, [userTable, eventsTable]);
631
+ emailSearchKms = new InMemoryKmsAdapter();
632
+ configurePiiSubjectKms(emailSearchKms);
633
+ });
634
+
635
+ afterEach(() => {
636
+ resetPiiSubjectKmsForTests();
637
+ });
638
+
639
+ test("erased email is unfindable, including after a search-index rebuild", async () => {
640
+ const email = "erase-me-1611@example.com";
641
+ const { id: userId } = await seedUser(emailSearchStack.db, {
642
+ email,
643
+ displayName: "Erase Me",
644
+ emailVerified: true,
645
+ });
646
+
647
+ // user is systemStream — its events (and the derived search doc) live on
648
+ // SYSTEM_TENANT_ID, not a regular tenant (see screens.ts:30 comment).
649
+ await emailSearchStack.eventDispatcher?.runOnce();
650
+ const before = await emailSearchStack.search.search(SYSTEM_TENANT_ID, email, {
651
+ filterType: "user",
652
+ });
653
+ expect(before.some((h) => String(h.entityId) === userId)).toBe(true);
654
+
655
+ await emailSearchStack.http.writeOk(
656
+ FORGET,
657
+ { subject: { kind: "user", userId }, reason: REASON },
658
+ TestUsers.systemAdmin,
659
+ );
660
+
661
+ const after = await emailSearchStack.search.search(SYSTEM_TENANT_ID, email, {
662
+ filterType: "user",
663
+ });
664
+ expect(after.some((h) => String(h.entityId) === userId)).toBe(false);
665
+
666
+ // A rebuild must not resurrect the erased subject's email — reindexEntity's
667
+ // hasErasedSearchableSubjectField skip has to hold for this real
668
+ // production field, not only for the framework's synthetic pii probe.
669
+ const rebuild = await reindexEntity(
670
+ emailSearchStack.db,
671
+ emailSearchStack.registry,
672
+ emailSearchStack.search,
673
+ "user",
674
+ SYSTEM_TENANT_ID,
675
+ );
676
+ expect(rebuild.failures).toHaveLength(0);
677
+
678
+ const afterRebuild = await emailSearchStack.search.search(SYSTEM_TENANT_ID, email, {
679
+ filterType: "user",
680
+ });
681
+ expect(afterRebuild.some((h) => String(h.entityId) === userId)).toBe(false);
682
+ });
683
+ });