@cosmicdrift/kumiko-bundled-features 0.220.1 → 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.
- package/package.json +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +6 -1
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/feature.ts +7 -4
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +2 -2
- package/src/tenant/handlers/update-member-roles.write.ts +1 -1
- package/src/tenant/i18n.ts +2 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +18 -5
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
|
@@ -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:
|
|
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:
|
|
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
|
|
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(
|
|
107
|
-
event.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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" })
|
|
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;
|
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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 =
|
|
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"
|
|
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
|
-
//
|
|
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
|
+
});
|