@cosmicdrift/kumiko-bundled-features 0.37.0 → 0.38.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 +5 -5
- package/src/auth-email-password/email-templates.ts +4 -0
- package/src/auth-email-password/errors.ts +84 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -10
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
- package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
- package/src/auth-email-password/handlers/login.write.ts +7 -51
- package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
- package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
- package/src/cap-counter/enforce-cap.ts +5 -0
- package/src/compliance-profiles/README.md +1 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -1
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
- package/src/custom-fields/db/queries/retention.ts +1 -0
- package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
- package/src/custom-fields/run-retention.ts +4 -22
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
- package/src/custom-fields/wire-for-entity.ts +4 -12
- package/src/custom-fields/wire-user-data-rights.ts +3 -22
- package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
- package/src/file-foundation/feature.ts +13 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
- package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
- package/src/files/README.md +1 -1
- package/src/subscription-stripe/feature.ts +5 -2
- package/src/template-resolver/feature.ts +1 -2
- package/src/template-resolver/handlers/list.query.ts +7 -14
- package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
- package/src/tenant/command-schemas.ts +1 -1
- package/src/tenant/feature.ts +1 -2
- package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
- package/src/user-data-rights/README.md +8 -8
- package/src/template-resolver/handlers/archive.write.ts +0 -39
- package/src/template-resolver/handlers/publish.write.ts +0 -42
- package/src/tenant/handlers/disable.write.ts +0 -18
- package/src/tenant/handlers/enable.write.ts +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -74,10 +74,10 @@
|
|
|
74
74
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
78
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
77
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.37.0",
|
|
78
|
+
"@cosmicdrift/kumiko-framework": "0.37.0",
|
|
79
|
+
"@cosmicdrift/kumiko-renderer": "0.37.0",
|
|
80
|
+
"@cosmicdrift/kumiko-renderer-web": "0.37.0",
|
|
81
81
|
"@mollie/api-client": "^4.5.0",
|
|
82
82
|
"@node-rs/argon2": "^2.0.2",
|
|
83
83
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -64,6 +64,7 @@ const STRINGS = {
|
|
|
64
64
|
de: {
|
|
65
65
|
resetSubject: (app: string) => `${app} — Passwort zurücksetzen`,
|
|
66
66
|
resetGreeting: "Hallo,",
|
|
67
|
+
// guard:dup-ok — false positive: i18n-String-Template, gleiche Arrow-Struktur wie anonymous fn in collect-table-metas
|
|
67
68
|
resetIntro: (app: string) =>
|
|
68
69
|
`du hast den Reset deines Passworts für ${app} angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen:`,
|
|
69
70
|
resetButton: "Passwort zurücksetzen",
|
|
@@ -160,6 +161,7 @@ function renderTokenEmail(spec: TokenEmailSpec): RenderedEmail {
|
|
|
160
161
|
|
|
161
162
|
// Plain inline-styled HTML — funktioniert in Gmail/Outlook/Apple-Mail
|
|
162
163
|
// ohne dass wir Tailwind oder eine HTML-mail-Lib reinziehen müssen.
|
|
164
|
+
// guard:dup-ok — Email-HTML (table-layout, inline CSS) ≠ Web-HTML (legal-pages/markdown.ts)
|
|
163
165
|
function renderShell(args: { title: string; bodyHtml: string }): string {
|
|
164
166
|
return `<!DOCTYPE html>
|
|
165
167
|
<html lang="en">
|
|
@@ -182,10 +184,12 @@ function renderShell(args: { title: string; bodyHtml: string }): string {
|
|
|
182
184
|
</html>`;
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
|
|
185
188
|
function renderButton(args: { url: string; label: string }): string {
|
|
186
189
|
return `<a href="${escapeHtmlAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
|
|
189
193
|
function renderFallbackUrl(args: { url: string; label: string }): string {
|
|
190
194
|
return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeHtmlAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
|
|
191
195
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
import { AuthErrors } from "./constants";
|
|
3
|
+
|
|
4
|
+
export function invalidCredentials() {
|
|
5
|
+
return writeFailure(
|
|
6
|
+
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
7
|
+
i18nKey: "auth.errors.invalidCredentials",
|
|
8
|
+
}),
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function invalidInviteToken() {
|
|
13
|
+
return writeFailure(
|
|
14
|
+
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
15
|
+
i18nKey: "auth.errors.invalidInviteToken",
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function inviteEmailMismatch() {
|
|
21
|
+
return writeFailure(
|
|
22
|
+
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
23
|
+
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function invalidResetToken() {
|
|
29
|
+
return writeFailure(
|
|
30
|
+
new UnprocessableError(AuthErrors.invalidResetToken, {
|
|
31
|
+
i18nKey: "auth.errors.invalidResetToken",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function invalidVerificationToken() {
|
|
37
|
+
return writeFailure(
|
|
38
|
+
new UnprocessableError(AuthErrors.invalidVerificationToken, {
|
|
39
|
+
i18nKey: "auth.errors.invalidVerificationToken",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function invalidSignupToken() {
|
|
45
|
+
return writeFailure(
|
|
46
|
+
new UnprocessableError(AuthErrors.invalidSignupToken, {
|
|
47
|
+
i18nKey: "auth.errors.invalidSignupToken",
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function noMembership() {
|
|
53
|
+
return writeFailure(
|
|
54
|
+
new UnprocessableError(AuthErrors.noMembership, {
|
|
55
|
+
i18nKey: "auth.errors.noMembership",
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function emailNotVerified() {
|
|
61
|
+
return writeFailure(
|
|
62
|
+
new UnprocessableError(AuthErrors.emailNotVerified, {
|
|
63
|
+
i18nKey: "auth.errors.emailNotVerified",
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// retryAfterSeconds drives the login/signup UI countdown — must stay > 0.
|
|
69
|
+
export function accountLocked(retryAfterSeconds: number) {
|
|
70
|
+
return writeFailure(
|
|
71
|
+
new UnprocessableError(AuthErrors.accountLocked, {
|
|
72
|
+
i18nKey: "auth.errors.accountLocked",
|
|
73
|
+
details: { retryAfterSeconds },
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function accountRestricted() {
|
|
79
|
+
return writeFailure(
|
|
80
|
+
new UnprocessableError(AuthErrors.accountRestricted, {
|
|
81
|
+
i18nKey: "auth.errors.accountRestricted",
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
2
|
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
|
|
6
|
-
import {
|
|
5
|
+
import { invalidCredentials } from "../errors";
|
|
7
6
|
import { hashPassword, verifyPassword } from "../password-hashing";
|
|
8
7
|
|
|
9
|
-
function invalidCredentials() {
|
|
10
|
-
return writeFailure(
|
|
11
|
-
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
12
|
-
i18nKey: "auth.errors.invalidCredentials",
|
|
13
|
-
}),
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
8
|
// Change-password — authenticated. The user supplies their current password
|
|
18
9
|
// (re-auth) and the new one. The new hash is written via ctx.writeAs(system)
|
|
19
10
|
// against the user feature's update handler; field-access on passwordHash
|
|
@@ -25,11 +25,7 @@ import {
|
|
|
25
25
|
type SessionUser,
|
|
26
26
|
type TenantId,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
-
import {
|
|
29
|
-
InternalError,
|
|
30
|
-
UnprocessableError,
|
|
31
|
-
writeFailure,
|
|
32
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
28
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
33
29
|
import { z } from "zod";
|
|
34
30
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
35
31
|
import {
|
|
@@ -41,7 +37,7 @@ import {
|
|
|
41
37
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
42
38
|
// kumiko-lint-ignore cross-feature-import login-style password-check
|
|
43
39
|
import { userTable } from "../../user/schema/user";
|
|
44
|
-
import {
|
|
40
|
+
import { invalidInviteToken, inviteEmailMismatch } from "../errors";
|
|
45
41
|
import {
|
|
46
42
|
burnInviteToken,
|
|
47
43
|
deleteInviteToken,
|
|
@@ -69,14 +65,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
69
65
|
{ entityName: "tenant-invitation" },
|
|
70
66
|
);
|
|
71
67
|
|
|
72
|
-
function invalidInviteToken() {
|
|
73
|
-
return writeFailure(
|
|
74
|
-
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
75
|
-
i18nKey: "auth.errors.invalidInviteToken",
|
|
76
|
-
}),
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
68
|
export function createInviteAcceptWithLoginHandler() {
|
|
81
69
|
return defineWriteHandler<
|
|
82
70
|
"invite-accept-with-login",
|
|
@@ -123,11 +111,7 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
123
111
|
|
|
124
112
|
// Email-Match vom User-Input (nicht aus session — User ist anon)
|
|
125
113
|
if (event.payload.email.toLowerCase() !== invitationEmail) {
|
|
126
|
-
return
|
|
127
|
-
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
128
|
-
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
114
|
+
return inviteEmailMismatch();
|
|
131
115
|
}
|
|
132
116
|
|
|
133
117
|
// Password-Check gegen userTable. Anti-enumeration: bei
|
|
@@ -22,11 +22,7 @@ import {
|
|
|
22
22
|
defineWriteHandler,
|
|
23
23
|
type TenantId,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import {
|
|
26
|
-
InternalError,
|
|
27
|
-
UnprocessableError,
|
|
28
|
-
writeFailure,
|
|
29
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
25
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
26
|
import { z } from "zod";
|
|
31
27
|
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
|
|
32
28
|
import {
|
|
@@ -34,11 +30,13 @@ import {
|
|
|
34
30
|
tenantInvitationEntity,
|
|
35
31
|
tenantInvitationsTable,
|
|
36
32
|
} from "../../tenant/invitation-table";
|
|
33
|
+
// kumiko-lint-ignore cross-feature-import direkter membership-Lookup (ungefiltert, s. alreadyMember-Kommentar)
|
|
34
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
37
35
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
|
|
38
36
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
39
37
|
// kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
|
|
40
38
|
import { userTable } from "../../user/schema/user";
|
|
41
|
-
import {
|
|
39
|
+
import { invalidInviteToken, inviteEmailMismatch } from "../errors";
|
|
42
40
|
import {
|
|
43
41
|
burnInviteToken,
|
|
44
42
|
deleteInviteToken,
|
|
@@ -63,14 +61,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
63
61
|
{ entityName: "tenant-invitation" },
|
|
64
62
|
);
|
|
65
63
|
|
|
66
|
-
function invalidInviteToken() {
|
|
67
|
-
return writeFailure(
|
|
68
|
-
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
69
|
-
i18nKey: "auth.errors.invalidInviteToken",
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
64
|
export function createInviteAcceptHandler() {
|
|
75
65
|
return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
|
|
76
66
|
name: "invite-accept",
|
|
@@ -122,22 +112,19 @@ export function createInviteAcceptHandler() {
|
|
|
122
112
|
});
|
|
123
113
|
const userEmail = userRow?.email;
|
|
124
114
|
if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
|
|
125
|
-
return
|
|
126
|
-
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
127
|
-
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
128
|
-
}),
|
|
129
|
-
);
|
|
115
|
+
return inviteEmailMismatch();
|
|
130
116
|
}
|
|
131
117
|
|
|
132
|
-
// Already-Member-Check
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
118
|
+
// Already-Member-Check direkt gegen die memberships-Projektion —
|
|
119
|
+
// NICHT via tenant:query:memberships, die disabled Tenants filtert:
|
|
120
|
+
// ein Re-Invite in einen (vorübergehend) disabled Tenant würde dort
|
|
121
|
+
// alreadyMember=false sehen und am Unique-Constraint scheitern.
|
|
122
|
+
// Idempotenz: schon Member → no-op + 200 mit alreadyMember=true.
|
|
123
|
+
const membershipRow = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
|
|
124
|
+
userId: event.user.id,
|
|
125
|
+
tenantId: invitationTenantId,
|
|
126
|
+
});
|
|
127
|
+
const alreadyMember = membershipRow !== undefined;
|
|
141
128
|
|
|
142
129
|
const dbConn = ctx.db.raw;
|
|
143
130
|
|
|
@@ -29,11 +29,7 @@ import {
|
|
|
29
29
|
type SessionUser,
|
|
30
30
|
type TenantId,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
-
import {
|
|
33
|
-
InternalError,
|
|
34
|
-
UnprocessableError,
|
|
35
|
-
writeFailure,
|
|
36
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
32
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
37
33
|
import { z } from "zod";
|
|
38
34
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
39
35
|
import {
|
|
@@ -45,7 +41,7 @@ import {
|
|
|
45
41
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
46
42
|
// kumiko-lint-ignore cross-feature-import existence-check
|
|
47
43
|
import { userTable } from "../../user/schema/user";
|
|
48
|
-
import {
|
|
44
|
+
import { invalidInviteToken } from "../errors";
|
|
49
45
|
import {
|
|
50
46
|
burnInviteToken,
|
|
51
47
|
deleteInviteToken,
|
|
@@ -73,14 +69,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
73
69
|
{ entityName: "tenant-invitation" },
|
|
74
70
|
);
|
|
75
71
|
|
|
76
|
-
function invalidInviteToken() {
|
|
77
|
-
return writeFailure(
|
|
78
|
-
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
79
|
-
i18nKey: "auth.errors.invalidInviteToken",
|
|
80
|
-
}),
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
72
|
export function createInviteSignupCompleteHandler() {
|
|
85
73
|
return defineWriteHandler<
|
|
86
74
|
"invite-signup-complete",
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
type SessionUser,
|
|
5
5
|
type TenantId,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
7
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
8
|
import { z } from "zod";
|
|
10
9
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
@@ -12,60 +11,17 @@ import { parseAuthUserRow } from "../auth-user-row";
|
|
|
12
11
|
import {
|
|
13
12
|
AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
|
|
14
13
|
AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
|
|
15
|
-
AuthErrors,
|
|
16
14
|
} from "../constants";
|
|
15
|
+
import {
|
|
16
|
+
accountLocked,
|
|
17
|
+
accountRestricted,
|
|
18
|
+
emailNotVerified,
|
|
19
|
+
invalidCredentials,
|
|
20
|
+
noMembership,
|
|
21
|
+
} from "../errors";
|
|
17
22
|
import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
|
|
18
23
|
import { verifyPassword } from "../password-hashing";
|
|
19
24
|
|
|
20
|
-
function invalidCredentials() {
|
|
21
|
-
return writeFailure(
|
|
22
|
-
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
23
|
-
i18nKey: "auth.errors.invalidCredentials",
|
|
24
|
-
}),
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function noMembership() {
|
|
29
|
-
return writeFailure(
|
|
30
|
-
new UnprocessableError(AuthErrors.noMembership, {
|
|
31
|
-
i18nKey: "auth.errors.noMembership",
|
|
32
|
-
}),
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function emailNotVerified() {
|
|
37
|
-
return writeFailure(
|
|
38
|
-
new UnprocessableError(AuthErrors.emailNotVerified, {
|
|
39
|
-
i18nKey: "auth.errors.emailNotVerified",
|
|
40
|
-
}),
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function accountLocked(retryAfterSeconds: number) {
|
|
45
|
-
return writeFailure(
|
|
46
|
-
new UnprocessableError(AuthErrors.accountLocked, {
|
|
47
|
-
i18nKey: "auth.errors.accountLocked",
|
|
48
|
-
// Seconds until auto-unlock — UI renders a countdown, clients can
|
|
49
|
-
// schedule a retry. Rounded up so the UI never shows 0 while the
|
|
50
|
-
// lock is still active.
|
|
51
|
-
details: { retryAfterSeconds },
|
|
52
|
-
}),
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// S2.U6 — DSGVO Art. 18 Account-Freeze. Distinct error code (nicht zu
|
|
57
|
-
// invalid_credentials collapsen) damit UI klar sagen kann "Account ist
|
|
58
|
-
// pausiert, hier klicken zum Aufheben". User weiss schon dass sein Konto
|
|
59
|
-
// restricted ist (er hat selbst die Restriction gesetzt), also kein
|
|
60
|
-
// Enumeration-Leak.
|
|
61
|
-
function accountRestricted() {
|
|
62
|
-
return writeFailure(
|
|
63
|
-
new UnprocessableError(AuthErrors.accountRestricted, {
|
|
64
|
-
i18nKey: "auth.errors.accountRestricted",
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
25
|
export type LoginHandlerOptions = {
|
|
70
26
|
// When true, a valid (email + password) login fails with email_not_verified
|
|
71
27
|
// if the user row's emailVerified flag is false. Enumeration-leak is
|
|
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { AuthErrors } from "../constants";
|
|
5
|
+
import { invalidResetToken } from "../errors";
|
|
5
6
|
import { hashPassword } from "../password-hashing";
|
|
6
7
|
import { verifyResetToken } from "../reset-token";
|
|
7
8
|
import { runConfirmTokenFlow } from "./confirm-token-flow";
|
|
@@ -10,14 +11,6 @@ export type ResetPasswordOptions = {
|
|
|
10
11
|
readonly hmacSecret: string;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
function invalidToken() {
|
|
14
|
-
return writeFailure(
|
|
15
|
-
new UnprocessableError(AuthErrors.invalidResetToken, {
|
|
16
|
-
i18nKey: "auth.errors.invalidResetToken",
|
|
17
|
-
}),
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
14
|
// Confirm step of the reset flow. Token-verify happens inline; the
|
|
22
15
|
// post-verify pipeline (burn, load user, memberships, try-all-tenants,
|
|
23
16
|
// burn-release-on-failure) lives in confirm-token-flow to stay in sync
|
|
@@ -45,12 +38,12 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
|
|
|
45
38
|
// the same invalid_reset_token error — a probing caller can't
|
|
46
39
|
// distinguish tampered from stale from random-string.
|
|
47
40
|
const verify = verifyResetToken(event.payload.token, opts.hmacSecret);
|
|
48
|
-
if (!verify.ok) return
|
|
41
|
+
if (!verify.ok) return invalidResetToken();
|
|
49
42
|
|
|
50
43
|
return runConfirmTokenFlow(ctx, verify.userId, verify.expiresAtMs, {
|
|
51
44
|
purpose: "reset",
|
|
52
45
|
redisRequiredMessage: "password-reset requires ctx.redis to enforce token single-use",
|
|
53
|
-
invalidToken,
|
|
46
|
+
invalidToken: invalidResetToken,
|
|
54
47
|
buildChanges: async () => ({
|
|
55
48
|
passwordHash: await hashPassword(event.payload.newPassword),
|
|
56
49
|
}),
|
|
@@ -26,17 +26,13 @@ import {
|
|
|
26
26
|
type SessionUser,
|
|
27
27
|
type TenantId,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
-
import {
|
|
30
|
-
InternalError,
|
|
31
|
-
UnprocessableError,
|
|
32
|
-
writeFailure,
|
|
33
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
29
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
34
30
|
import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
|
|
35
31
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
36
32
|
import { z } from "zod";
|
|
37
33
|
// kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
|
|
38
34
|
import { tenantTable } from "../../tenant/schema/tenant";
|
|
39
|
-
import {
|
|
35
|
+
import { invalidSignupToken } from "../errors";
|
|
40
36
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
41
37
|
import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
|
|
42
38
|
import {
|
|
@@ -63,14 +59,6 @@ export type SignupConfirmData = {
|
|
|
63
59
|
readonly tenantKey: string;
|
|
64
60
|
};
|
|
65
61
|
|
|
66
|
-
function invalidSignupToken() {
|
|
67
|
-
return writeFailure(
|
|
68
|
-
new UnprocessableError(AuthErrors.invalidSignupToken, {
|
|
69
|
-
i18nKey: "auth.errors.invalidSignupToken",
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
62
|
export function createSignupConfirmHandler() {
|
|
75
63
|
return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
|
|
76
64
|
name: "signup-confirm",
|
|
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { AuthErrors } from "../constants";
|
|
5
|
+
import { invalidVerificationToken } from "../errors";
|
|
5
6
|
import { verifyVerificationToken } from "../verification-token";
|
|
6
7
|
import { runConfirmTokenFlow } from "./confirm-token-flow";
|
|
7
8
|
|
|
@@ -15,14 +16,6 @@ const VerifyEmailSchema = z.object({
|
|
|
15
16
|
|
|
16
17
|
export type VerifyEmailData = { readonly kind: "verified" } | { readonly kind: "already-verified" };
|
|
17
18
|
|
|
18
|
-
function invalidToken() {
|
|
19
|
-
return writeFailure(
|
|
20
|
-
new UnprocessableError(AuthErrors.invalidVerificationToken, {
|
|
21
|
-
i18nKey: "auth.errors.invalidVerificationToken",
|
|
22
|
-
}),
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
19
|
// Sets user.emailVerified = true on a valid token. Idempotent via the
|
|
27
20
|
// `alreadyDone` short-circuit — when the row already reads verified
|
|
28
21
|
// (reached through another flow), we skip the write but keep the burn
|
|
@@ -44,12 +37,12 @@ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
|
|
|
44
37
|
}
|
|
45
38
|
|
|
46
39
|
const verify = verifyVerificationToken(event.payload.token, opts.hmacSecret);
|
|
47
|
-
if (!verify.ok) return
|
|
40
|
+
if (!verify.ok) return invalidVerificationToken();
|
|
48
41
|
|
|
49
42
|
return runConfirmTokenFlow<VerifyEmailData>(ctx, verify.userId, verify.expiresAtMs, {
|
|
50
43
|
purpose: "verify",
|
|
51
44
|
redisRequiredMessage: "email-verification requires ctx.redis to enforce token single-use",
|
|
52
|
-
invalidToken,
|
|
45
|
+
invalidToken: invalidVerificationToken,
|
|
53
46
|
buildChanges: async () => ({ emailVerified: true }),
|
|
54
47
|
successData: { kind: "verified" },
|
|
55
48
|
alreadyDone: {
|
|
@@ -111,3 +111,27 @@ describe("TenantSwitcher", () => {
|
|
|
111
111
|
expect(session.switchTenant).not.toHaveBeenCalled();
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
describe("TenantSwitcher — key-only-Fallback im geöffneten Dropdown", () => {
|
|
116
|
+
test("Membership ohne name rendert ihren key als Dropdown-Label", async () => {
|
|
117
|
+
const user = userEvent.setup();
|
|
118
|
+
const session = makeSessionApi({
|
|
119
|
+
activeTenantId: "00000000-0000-4000-8000-000000000001",
|
|
120
|
+
tenants: [
|
|
121
|
+
{
|
|
122
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
123
|
+
roles: ["Admin"],
|
|
124
|
+
name: "Status",
|
|
125
|
+
key: "status",
|
|
126
|
+
},
|
|
127
|
+
{ tenantId: "00000000-0000-4000-8000-000000000002", roles: ["Admin"], key: "demo" },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
renderWithProviders(<TenantSwitcher />, { session });
|
|
131
|
+
await user.click(screen.getByRole("button", { name: /Status/ }));
|
|
132
|
+
// Der key-only-Pfad (kein name) muss im Dropdown sichtbar werden —
|
|
133
|
+
// nicht das UUID-Präfix "00000000".
|
|
134
|
+
expect(screen.getByText("demo")).toBeTruthy();
|
|
135
|
+
expect(screen.queryByText("00000000")).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -34,6 +34,7 @@ export function ForgotPasswordScreen({
|
|
|
34
34
|
const [done, setDone] = useState(false);
|
|
35
35
|
const [error, setError] = useState<string | null>(null);
|
|
36
36
|
|
|
37
|
+
// guard:dup-ok — gleiches Submit-Muster wie signup-screen, aber verschiedene API-Endpoints und State
|
|
37
38
|
const doSubmit = async (): Promise<void> => {
|
|
38
39
|
setSubmitting(true);
|
|
39
40
|
setError(null);
|
|
@@ -63,7 +63,8 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
|
|
|
63
63
|
const nameOf = (tenantId: string): string => {
|
|
64
64
|
if (tenantName !== undefined) return tenantName(tenantId);
|
|
65
65
|
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
66
|
-
|
|
66
|
+
// || statt ??: ein leerer name/key-String darf nicht als Label durchrutschen.
|
|
67
|
+
return membership?.name || membership?.key || tenantId.slice(0, 8);
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
// Rendering-Gate: kein User → nix; nur ein Tenant → auch nix
|
|
@@ -413,6 +413,11 @@ export type StockCapResult =
|
|
|
413
413
|
* (ein Delete gibt den Slot sofort frei), braucht keine Counter-Tabelle und
|
|
414
414
|
* kein Increment/Decrement-Bookkeeping. Misst einen Bestand, keinen Fluss.
|
|
415
415
|
*
|
|
416
|
+
* **TOCTOU-Caveat:** Zählen und Schreiben sind nicht atomar — zwei parallele
|
|
417
|
+
* Creates können beide `ok` sehen und das Limit um eins überschreiten (gleiche
|
|
418
|
+
* Race wie bei {@link enforceCap}). Exakt harte Slots brauchen zusätzliche
|
|
419
|
+
* Serialisierung im Create-Pfad (z.B. Unique-Index auf tenantId+slot).
|
|
420
|
+
*
|
|
416
421
|
* Reine Funktion — wirft NICHT und mappt KEINEN HTTP-Status. Ein erreichtes
|
|
417
422
|
* Stock-Limit heißt „Upgrade nötig", nicht „retry later" (429): der Caller
|
|
418
423
|
* entscheidet die Reaktion, typisch ein app-eigener 422/`upgrade_required`
|
|
@@ -78,7 +78,7 @@ explizit als eigene Entity.
|
|
|
78
78
|
|
|
79
79
|
## Tests
|
|
80
80
|
|
|
81
|
-
`__tests__/compliance-profiles.integration.ts` — 9 full-stack Tests via
|
|
81
|
+
`__tests__/compliance-profiles.integration.test.ts` — 9 full-stack Tests via
|
|
82
82
|
`setupTestStack` + echte HTTP-Calls (Memory: `feedback_no_fake_dispatcher`):
|
|
83
83
|
list-profiles, for-tenant ohne/mit Setting, set-profile als TenantAdmin /
|
|
84
84
|
Member (403) / mit Override / mit invalidem JSON / mit Array statt Object /
|
|
@@ -12,7 +12,7 @@ describe("createCustomFieldsFeature shape", () => {
|
|
|
12
12
|
test("registers field-definition entity + 6 write-handlers + 1 query-handler", () => {
|
|
13
13
|
const feature = createCustomFieldsFeature();
|
|
14
14
|
|
|
15
|
-
expect(Object.keys(feature.entities)).toContain("field-definition");
|
|
15
|
+
expect(Object.keys(feature.entities ?? {})).toContain("field-definition");
|
|
16
16
|
|
|
17
17
|
const writeHandlerNames = Object.keys(feature.writeHandlers);
|
|
18
18
|
expect(writeHandlerNames).toEqual(
|
|
@@ -40,7 +40,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
40
40
|
);
|
|
41
41
|
|
|
42
42
|
// 3. postQuery entity-hook on "property"
|
|
43
|
-
expect(feature.entityHooks
|
|
43
|
+
expect(feature.entityHooks?.postQuery?.["property"]).toHaveLength(1);
|
|
44
44
|
|
|
45
45
|
// 4. search-payload-extension on "property"
|
|
46
46
|
expect(feature.searchPayloadExtensions!["property"]).toHaveLength(1);
|
|
@@ -52,7 +52,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
52
52
|
wireCustomFieldsFor(r, "property", propertyTable);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
const hook = feature.entityHooks
|
|
55
|
+
const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
|
|
56
56
|
expect(hook).toBeDefined();
|
|
57
57
|
const result = await hook?.(
|
|
58
58
|
{
|
|
@@ -82,7 +82,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
82
82
|
wireCustomFieldsFor(r, "property", propertyTable);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
const hook = feature.entityHooks
|
|
85
|
+
const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
|
|
86
86
|
const result = await hook?.(
|
|
87
87
|
{
|
|
88
88
|
entityName: "property",
|
|
@@ -111,7 +111,7 @@ describe("wireCustomFieldsFor", () => {
|
|
|
111
111
|
wireCustomFieldsFor(r, "property", propertyTable);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
const hook = feature.entityHooks
|
|
114
|
+
const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
|
|
115
115
|
const result = await hook?.(
|
|
116
116
|
{
|
|
117
117
|
entityName: "property",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
|
+
// guard:dup-ok — andere SQL als selectFieldDefinitionsForEntity; gleiche Bezeichner, verschiedene Queries
|
|
4
5
|
export async function selectFieldDefinitionsWithSerialized(
|
|
5
6
|
db: DbRunner,
|
|
6
7
|
entityName: string,
|
|
@@ -43,3 +43,14 @@ export function parseSerializedField(raw: unknown): SerializedFieldShape | null
|
|
|
43
43
|
const parsed = typeof raw === "string" ? parseJsonSafe<unknown>(raw, null) : raw;
|
|
44
44
|
return isShape(parsed) ? parsed : null;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
export interface FieldDefinitionRow {
|
|
48
|
+
readonly field_key: string;
|
|
49
|
+
readonly serialized_field: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
|
|
53
|
+
if (!value || typeof value !== "object") return false;
|
|
54
|
+
if (!("field_key" in value)) return false;
|
|
55
|
+
return typeof value.field_key === "string";
|
|
56
|
+
}
|