@cosmicdrift/kumiko-bundled-features 0.289.0 → 0.291.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/auth-email-password/__tests__/query-as-member.integration.test.ts +15 -2
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +31 -2
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +120 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +72 -0
- package/src/auth-email-password/web/auth-form-logic.ts +5 -5
- package/src/auth-email-password/web/invite-accept-screen.tsx +46 -21
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
- package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -2
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -0
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +2 -0
- package/src/delivery/feature.ts +6 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +8 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +2 -0
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +2 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +2 -0
- package/src/jobs/__tests__/tenant-job-failures.integration.test.ts +275 -0
- package/src/jobs/changes.json +7 -0
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/db/queries/retention.ts +17 -1
- package/src/jobs/feature.ts +7 -1
- package/src/jobs/handlers/tenant-failures.query.ts +82 -0
- package/src/jobs/index.ts +1 -0
- package/src/jobs/job-run-logger.ts +73 -4
- package/src/jobs/tenant-job-failure-table.ts +39 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.test.ts +69 -1
- package/src/rate-limiting/changes.json +9 -1
- package/src/rate-limiting/constants.ts +1 -0
- package/src/rate-limiting/handlers/bucket-access.ts +39 -0
- package/src/rate-limiting/handlers/status.query.ts +8 -2
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +3 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +4 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.291.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>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.291.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.291.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.291.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.291.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.291.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.291.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.291.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.291.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -563,6 +563,19 @@ async function readAsMemberErr(
|
|
|
563
563
|
);
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
// The request-level teardown gate (auth-middleware) answers 410 before a
|
|
567
|
+
// handler runs, so a teardown tenant can only be probed for the dispatcher's
|
|
568
|
+
// own active-membership rejection through the stack's dispatcher.
|
|
569
|
+
async function readAsMemberErrViaDispatcher(userId: string, targetQn: string) {
|
|
570
|
+
const result = await stack.dispatcher.write(
|
|
571
|
+
"queryasmemberprobe:write:read-as-member",
|
|
572
|
+
{ userId, targetQn, payload: {} },
|
|
573
|
+
admin,
|
|
574
|
+
);
|
|
575
|
+
if (result.isSuccess) throw new Error("expected read-as-member to fail");
|
|
576
|
+
return result.error;
|
|
577
|
+
}
|
|
578
|
+
|
|
566
579
|
async function createNote(ownerId: string, body: string): Promise<void> {
|
|
567
580
|
await stack.http.writeOk("queryasmemberprobe:write:note:create", { ownerId, body }, admin);
|
|
568
581
|
}
|
|
@@ -693,7 +706,7 @@ describe("ctx.queryAsMember — AccessDenied, same generic error for every rejec
|
|
|
693
706
|
await addMembership(userId, TENANT_A);
|
|
694
707
|
await setTenantStatus(TENANT_A, "destroying");
|
|
695
708
|
|
|
696
|
-
const err = await
|
|
709
|
+
const err = await readAsMemberErrViaDispatcher(userId, WHOAMI_QN);
|
|
697
710
|
expect(err.code).toBe("access_denied");
|
|
698
711
|
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
699
712
|
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
@@ -707,7 +720,7 @@ describe("ctx.queryAsMember — AccessDenied, same generic error for every rejec
|
|
|
707
720
|
await addMembership(userId, TENANT_A);
|
|
708
721
|
await setTenantStatus(TENANT_A, "destroyRequested");
|
|
709
722
|
|
|
710
|
-
const err = await
|
|
723
|
+
const err = await readAsMemberErrViaDispatcher(userId, WHOAMI_QN);
|
|
711
724
|
expect(err.code).toBe("access_denied");
|
|
712
725
|
expect(err.message).toBe(GENERIC_MESSAGE);
|
|
713
726
|
expect(err.details).toEqual(GENERIC_DETAILS);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.291.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "loggedInHref callbacks on SignupCompleteScreen and InviteAcceptScreen now receive the roles granted by the flow",
|
|
6
|
+
"detail": "Apps can route a freshly activated user to a screen their role can actually open, instead of a fixed path that may render Access denied. The string form of loggedInHref is unchanged; the function form gains a roles field next to the existing tenantKey/tenantId."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.286.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -34,11 +34,40 @@ describe("retryAfterMinutes", () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
describe("resolveLoggedInHref", () => {
|
|
37
|
+
type SignupArgs = { tenantKey: string; roles: readonly string[] };
|
|
38
|
+
|
|
37
39
|
test("string href returned as-is", () => {
|
|
38
|
-
expect(resolveLoggedInHref("/", "acme")).toBe("/");
|
|
40
|
+
expect(resolveLoggedInHref<SignupArgs>("/", { tenantKey: "acme", roles: ["User"] })).toBe("/");
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
test("function href receives tenantKey", () => {
|
|
42
|
-
expect(
|
|
44
|
+
expect(
|
|
45
|
+
resolveLoggedInHref<SignupArgs>(({ tenantKey }) => `/${tenantKey}/`, {
|
|
46
|
+
tenantKey: "acme",
|
|
47
|
+
roles: [],
|
|
48
|
+
}),
|
|
49
|
+
).toBe("/acme/");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("function href can route on the roles the server returned", () => {
|
|
53
|
+
const byRole = ({ tenantKey, roles }: SignupArgs): string =>
|
|
54
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`;
|
|
55
|
+
|
|
56
|
+
expect(resolveLoggedInHref(byRole, { tenantKey: "acme", roles: ["Dealer"] })).toBe(
|
|
57
|
+
"/acme/vehicle-start",
|
|
58
|
+
);
|
|
59
|
+
expect(resolveLoggedInHref(byRole, { tenantKey: "acme", roles: ["SystemAdmin"] })).toBe(
|
|
60
|
+
"/a/waitlist-list",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("args object shape is per-screen — invite passes tenantId", () => {
|
|
65
|
+
expect(
|
|
66
|
+
resolveLoggedInHref(
|
|
67
|
+
({ tenantId, roles }: { tenantId: string; roles: readonly string[] }) =>
|
|
68
|
+
`/${tenantId}/${roles[0] ?? "none"}`,
|
|
69
|
+
{ tenantId: "t1", roles: ["Editor"] },
|
|
70
|
+
),
|
|
71
|
+
).toBe("/t1/Editor");
|
|
43
72
|
});
|
|
44
73
|
});
|
|
@@ -91,6 +91,53 @@ describe("InviteAcceptScreen — logged-in branch", () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test("branch 1: loggedInHref-Function bekommt die Rolle aus der invite-accept-Response", async () => {
|
|
95
|
+
globalThis.fetch = mock(
|
|
96
|
+
async () =>
|
|
97
|
+
new Response(
|
|
98
|
+
JSON.stringify({ tenantId: "tenant-new", role: "Dealer", alreadyMember: false }),
|
|
99
|
+
{ status: 200 },
|
|
100
|
+
),
|
|
101
|
+
) as unknown as typeof fetch;
|
|
102
|
+
const assign = mock<(url: string) => void>();
|
|
103
|
+
Object.defineProperty(window, "location", {
|
|
104
|
+
configurable: true,
|
|
105
|
+
writable: true,
|
|
106
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
renderWithProviders(
|
|
110
|
+
<InviteAcceptScreen
|
|
111
|
+
token="tok-123"
|
|
112
|
+
loggedInHref={({ tenantId, roles }) =>
|
|
113
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantId}/vehicle-start`
|
|
114
|
+
}
|
|
115
|
+
/>,
|
|
116
|
+
{ session: makeSessionApi({ status: "authenticated" }) },
|
|
117
|
+
);
|
|
118
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(assign).toHaveBeenCalledWith("/tenant-new/vehicle-start");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("String-Form bleibt unverändert gültig", async () => {
|
|
125
|
+
const assign = mock<(url: string) => void>();
|
|
126
|
+
Object.defineProperty(window, "location", {
|
|
127
|
+
configurable: true,
|
|
128
|
+
writable: true,
|
|
129
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
renderWithProviders(<InviteAcceptScreen token="tok-123" loggedInHref="/fixed-landing" />, {
|
|
133
|
+
session: makeSessionApi({ status: "authenticated" }),
|
|
134
|
+
});
|
|
135
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(assign).toHaveBeenCalledWith("/fixed-landing");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
94
141
|
test("accept logged-in failure shows invalidInviteToken banner", async () => {
|
|
95
142
|
globalThis.fetch = mock(
|
|
96
143
|
async () => new Response(null, { status: 422 }),
|
|
@@ -154,6 +201,79 @@ describe("InviteAcceptScreen — anonymous branches", () => {
|
|
|
154
201
|
});
|
|
155
202
|
});
|
|
156
203
|
|
|
204
|
+
test("branch 2/3: die Session-Rollen aus user.roles schlagen die Invitation-Rolle", async () => {
|
|
205
|
+
globalThis.fetch = mock(
|
|
206
|
+
async () =>
|
|
207
|
+
new Response(
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
token: "jwt",
|
|
210
|
+
user: { id: "u1", tenantId: "t1", roles: ["Dealer"] },
|
|
211
|
+
tenantId: "t1",
|
|
212
|
+
role: "SystemAdmin",
|
|
213
|
+
}),
|
|
214
|
+
{ status: 200 },
|
|
215
|
+
),
|
|
216
|
+
) as unknown as typeof fetch;
|
|
217
|
+
const assign = mock<(url: string) => void>();
|
|
218
|
+
Object.defineProperty(window, "location", {
|
|
219
|
+
configurable: true,
|
|
220
|
+
writable: true,
|
|
221
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
render(
|
|
225
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
226
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
227
|
+
<InviteAcceptScreen
|
|
228
|
+
token="tok-123"
|
|
229
|
+
loggedInHref={({ tenantId, roles }) => `/${tenantId}/${roles.join("+")}`}
|
|
230
|
+
/>
|
|
231
|
+
</LocaleProvider>
|
|
232
|
+
</PrimitivesProvider>,
|
|
233
|
+
);
|
|
234
|
+
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: "a@example.com" } });
|
|
235
|
+
fireEvent.change(screen.getByLabelText(/^Password/), { target: { value: "secret123" } });
|
|
236
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept + sign in" }));
|
|
237
|
+
await waitFor(() => {
|
|
238
|
+
expect(assign).toHaveBeenCalledWith("/t1/Dealer");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("MFA-Challenge (200 ohne Rollen) liefert eine leere Rollenliste statt undefined", async () => {
|
|
243
|
+
globalThis.fetch = mock(
|
|
244
|
+
async () =>
|
|
245
|
+
new Response(JSON.stringify({ mfaRequired: true, challengeToken: "ch" }), { status: 200 }),
|
|
246
|
+
) as unknown as typeof fetch;
|
|
247
|
+
const assign = mock<(url: string) => void>();
|
|
248
|
+
Object.defineProperty(window, "location", {
|
|
249
|
+
configurable: true,
|
|
250
|
+
writable: true,
|
|
251
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
252
|
+
});
|
|
253
|
+
const seen: (readonly string[])[] = [];
|
|
254
|
+
|
|
255
|
+
render(
|
|
256
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
257
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
258
|
+
<InviteAcceptScreen
|
|
259
|
+
token="tok-123"
|
|
260
|
+
loggedInHref={({ roles }) => {
|
|
261
|
+
seen.push(roles);
|
|
262
|
+
return "/";
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</LocaleProvider>
|
|
266
|
+
</PrimitivesProvider>,
|
|
267
|
+
);
|
|
268
|
+
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: "a@example.com" } });
|
|
269
|
+
fireEvent.change(screen.getByLabelText(/^Password/), { target: { value: "secret123" } });
|
|
270
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept + sign in" }));
|
|
271
|
+
await waitFor(() => {
|
|
272
|
+
expect(assign).toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
expect(seen).toEqual([[]]);
|
|
275
|
+
});
|
|
276
|
+
|
|
157
277
|
test("anon failure shows invalidInviteToken banner", async () => {
|
|
158
278
|
globalThis.fetch = mock(
|
|
159
279
|
async () => new Response(null, { status: 422 }),
|
|
@@ -107,6 +107,78 @@ describe("SignupCompleteScreen", () => {
|
|
|
107
107
|
}
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("loggedInHref-Function bekommt die Rollen aus der signup-confirm-Response", async () => {
|
|
111
|
+
globalThis.fetch = mock(
|
|
112
|
+
async () =>
|
|
113
|
+
new Response(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
user: { id: "u1", tenantId: "t1", roles: ["Dealer"] },
|
|
116
|
+
tenantKey: "acme",
|
|
117
|
+
}),
|
|
118
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
119
|
+
),
|
|
120
|
+
) as unknown as typeof fetch;
|
|
121
|
+
|
|
122
|
+
renderWithProviders(
|
|
123
|
+
<SignupCompleteScreen
|
|
124
|
+
token="abc-token"
|
|
125
|
+
loggedInHref={({ tenantKey, roles }) =>
|
|
126
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`
|
|
127
|
+
}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
fillPasswords("validpass1", "validpass1");
|
|
131
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
135
|
+
"/acme/vehicle-start",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("gleiche Function, SystemAdmin-Rolle → anderes Ziel", async () => {
|
|
141
|
+
globalThis.fetch = mock(
|
|
142
|
+
async () =>
|
|
143
|
+
new Response(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
user: { id: "u1", tenantId: "t1", roles: ["SystemAdmin"] },
|
|
146
|
+
tenantKey: "acme",
|
|
147
|
+
}),
|
|
148
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
149
|
+
),
|
|
150
|
+
) as unknown as typeof fetch;
|
|
151
|
+
|
|
152
|
+
renderWithProviders(
|
|
153
|
+
<SignupCompleteScreen
|
|
154
|
+
token="abc-token"
|
|
155
|
+
loggedInHref={({ tenantKey, roles }) =>
|
|
156
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`
|
|
157
|
+
}
|
|
158
|
+
/>,
|
|
159
|
+
);
|
|
160
|
+
fillPasswords("validpass1", "validpass1");
|
|
161
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
165
|
+
"/a/waitlist-list",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("String-Form bleibt unverändert gültig und ignoriert die Rollen", async () => {
|
|
171
|
+
renderWithProviders(<SignupCompleteScreen token="abc-token" loggedInHref="/fixed-landing" />);
|
|
172
|
+
fillPasswords("validpass1", "validpass1");
|
|
173
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
177
|
+
"/fixed-landing",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
110
182
|
test("mismatch → client-side error, kein fetch-Call", async () => {
|
|
111
183
|
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
112
184
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
@@ -21,10 +21,10 @@ export function retryAfterMinutes(retryAfterSeconds?: number): number | undefine
|
|
|
21
21
|
return Math.ceil(retryAfterSeconds / 60);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Resolves post-login redirect: string template or function of
|
|
25
|
-
export function resolveLoggedInHref(
|
|
26
|
-
href: string | ((args:
|
|
27
|
-
|
|
24
|
+
/** Resolves post-login redirect: string template or function of the screen's args. */
|
|
25
|
+
export function resolveLoggedInHref<Args>(
|
|
26
|
+
href: string | ((args: Args) => string),
|
|
27
|
+
args: Args,
|
|
28
28
|
): string {
|
|
29
|
-
return typeof href === "function" ? href(
|
|
29
|
+
return typeof href === "function" ? href(args) : href;
|
|
30
30
|
}
|
|
@@ -19,21 +19,40 @@
|
|
|
19
19
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
20
20
|
import { type FormEvent, type ReactNode, useContext, useState } from "react";
|
|
21
21
|
import { csrfHeader } from "./auth-client";
|
|
22
|
+
import { resolveLoggedInHref } from "./auth-form-logic";
|
|
22
23
|
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
23
24
|
import { SessionContext, UNAUTHENTICATED } from "./session";
|
|
24
25
|
|
|
25
26
|
export type InviteAcceptScreenProps = {
|
|
26
27
|
readonly title?: string;
|
|
27
28
|
readonly token?: string;
|
|
28
|
-
/** Where to redirect on success. Default "/" —
|
|
29
|
-
*
|
|
30
|
-
|
|
29
|
+
/** Where to redirect on success. Default "/" — multi-tenant apps can pass
|
|
30
|
+
* `(data) => "/${data.tenantId}/"`. Function-form receives the roles this
|
|
31
|
+
* flow grants in the target tenant; branch 1 reports the invitation's
|
|
32
|
+
* role, which for an existing member is not their full role set there. */
|
|
33
|
+
readonly loggedInHref?:
|
|
34
|
+
| string
|
|
35
|
+
| ((args: { tenantId: string; roles: readonly string[] }) => string);
|
|
31
36
|
/** Login-Href für "Mit anderem Account anmelden". Default "/login". */
|
|
32
37
|
readonly loginHref?: string;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
type Mode = "loggedin" | "anon-existing" | "anon-new";
|
|
36
41
|
|
|
42
|
+
// All three accept routes answer 200 with tenantId + the invitation role;
|
|
43
|
+
// the two session-minting branches additionally carry the stripped session
|
|
44
|
+
// roles. An MFA challenge is also a 200 and carries none of them.
|
|
45
|
+
type InviteAcceptResponse = {
|
|
46
|
+
readonly tenantId: string;
|
|
47
|
+
readonly role?: string;
|
|
48
|
+
readonly user?: { readonly roles?: readonly string[] };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function grantedRoles(data: InviteAcceptResponse): readonly string[] {
|
|
52
|
+
if (data.user?.roles !== undefined) return data.user.roles;
|
|
53
|
+
return data.role === undefined ? [] : [data.role];
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
export function InviteAcceptScreen({
|
|
38
57
|
title,
|
|
39
58
|
token: tokenProp,
|
|
@@ -70,12 +89,14 @@ export function InviteAcceptScreen({
|
|
|
70
89
|
});
|
|
71
90
|
setSubmitting(false);
|
|
72
91
|
if (res.ok) {
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
:
|
|
78
|
-
|
|
92
|
+
// @cast-boundary engine-payload — auth route JSON
|
|
93
|
+
const data = (await res.json()) as InviteAcceptResponse;
|
|
94
|
+
window.location.assign(
|
|
95
|
+
resolveLoggedInHref(loggedInHref, {
|
|
96
|
+
tenantId: data.tenantId,
|
|
97
|
+
roles: grantedRoles(data),
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
79
100
|
return;
|
|
80
101
|
}
|
|
81
102
|
setError(t("auth.errors.invalidInviteToken"));
|
|
@@ -93,12 +114,14 @@ export function InviteAcceptScreen({
|
|
|
93
114
|
});
|
|
94
115
|
setSubmitting(false);
|
|
95
116
|
if (res.ok) {
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
:
|
|
101
|
-
|
|
117
|
+
// @cast-boundary engine-payload — auth route JSON
|
|
118
|
+
const data = (await res.json()) as InviteAcceptResponse;
|
|
119
|
+
window.location.assign(
|
|
120
|
+
resolveLoggedInHref(loggedInHref, {
|
|
121
|
+
tenantId: data.tenantId,
|
|
122
|
+
roles: grantedRoles(data),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
102
125
|
return;
|
|
103
126
|
}
|
|
104
127
|
setError(t("auth.errors.invalidInviteToken"));
|
|
@@ -116,12 +139,14 @@ export function InviteAcceptScreen({
|
|
|
116
139
|
});
|
|
117
140
|
setSubmitting(false);
|
|
118
141
|
if (res.ok) {
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
:
|
|
124
|
-
|
|
142
|
+
// @cast-boundary engine-payload — auth route JSON
|
|
143
|
+
const data = (await res.json()) as InviteAcceptResponse;
|
|
144
|
+
window.location.assign(
|
|
145
|
+
resolveLoggedInHref(loggedInHref, {
|
|
146
|
+
tenantId: data.tenantId,
|
|
147
|
+
roles: grantedRoles(data),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
125
150
|
return;
|
|
126
151
|
}
|
|
127
152
|
setError(t("auth.errors.invalidInviteToken"));
|
|
@@ -25,8 +25,11 @@ export type SignupCompleteScreenProps = {
|
|
|
25
25
|
readonly title?: string;
|
|
26
26
|
/** Override for the URL token — server-rendered apps can pass it. Default: `?token=...`. */
|
|
27
27
|
readonly token?: string;
|
|
28
|
-
/** Where to send the user after activation. Function-form receives the tenantKey
|
|
29
|
-
|
|
28
|
+
/** Where to send the user after activation. Function-form receives the tenantKey and the
|
|
29
|
+
* roles granted by this flow in that tenant — for role-dependent landing targets. Default "/". */
|
|
30
|
+
readonly loggedInHref?:
|
|
31
|
+
| string
|
|
32
|
+
| ((args: { tenantKey: string; roles: readonly string[] }) => string);
|
|
30
33
|
/** Href for the "already have an account?" link when the token is missing. Default "/login". */
|
|
31
34
|
readonly loginHref?: string;
|
|
32
35
|
};
|
|
@@ -64,7 +67,12 @@ export function SignupCompleteScreen({
|
|
|
64
67
|
// Cookies are already set (auto-login). Show a confirmation with an
|
|
65
68
|
// explicit continue button instead of navigating away silently —
|
|
66
69
|
// the user otherwise gets no signal that activation worked.
|
|
67
|
-
setContinueHref(
|
|
70
|
+
setContinueHref(
|
|
71
|
+
resolveLoggedInHref(loggedInHref, {
|
|
72
|
+
tenantKey: res.data.tenantKey,
|
|
73
|
+
roles: res.data.user.roles,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
68
76
|
return;
|
|
69
77
|
}
|
|
70
78
|
if (res.error.reason === "invalid_signup_token") {
|
|
@@ -375,12 +375,16 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
|
|
|
375
375
|
resetTenantLifecycleGateCacheForTests();
|
|
376
376
|
|
|
377
377
|
try {
|
|
378
|
-
|
|
378
|
+
// Via the dispatcher, not HTTP: the request-level teardown gate answers
|
|
379
|
+
// 410 before /api/write reaches the handler, and this test asserts the
|
|
380
|
+
// handler's own challenge-token gate.
|
|
381
|
+
const result = await stack.dispatcher.write(
|
|
379
382
|
AuthMfaHandlers.verify,
|
|
380
383
|
{ challengeToken, code: currentTotpCode(secret) },
|
|
381
384
|
GUEST,
|
|
382
385
|
);
|
|
383
|
-
|
|
386
|
+
if (result.isSuccess) throw new Error("expected mfa verify to fail");
|
|
387
|
+
expectErrorIncludes(result.error, "invalid_challenge_token");
|
|
384
388
|
} finally {
|
|
385
389
|
await asRawClient(stack.db).unsafe(
|
|
386
390
|
`UPDATE "${tenantTable.tableName}" SET status = $1 WHERE id = $2`,
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "../../compliance-profiles";
|
|
45
45
|
import { createConfigFeature } from "../../config";
|
|
46
46
|
import { createTenantFeature } from "../../tenant/feature";
|
|
47
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
47
48
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
48
49
|
import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id";
|
|
49
50
|
import {
|
|
@@ -147,6 +148,7 @@ beforeAll(async () => {
|
|
|
147
148
|
// subscriptionsProjectionTable wird von setupTestStack automatisch
|
|
148
149
|
// gepusht (r.projection mit `table`-Property → auto-push).
|
|
149
150
|
await createEventsTable(db);
|
|
151
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
150
152
|
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
151
153
|
// providerCustomerId/providerSubscriptionId are `personal: "tenant"`
|
|
152
154
|
// PII-subject fields — no executor wires the KMS automatically here (raw r.projection,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "../../compliance-profiles";
|
|
31
31
|
import { createConfigFeature } from "../../config";
|
|
32
32
|
import { createTenantFeature } from "../../tenant/feature";
|
|
33
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
33
34
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
34
35
|
import type { TierMap } from "../../tier-engine/compose-app";
|
|
35
36
|
import { TierEngineHandlers } from "../../tier-engine/constants";
|
|
@@ -112,6 +113,7 @@ beforeAll(async () => {
|
|
|
112
113
|
],
|
|
113
114
|
});
|
|
114
115
|
await createEventsTable(stack.db);
|
|
116
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
115
117
|
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
116
118
|
await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
|
|
117
119
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
package/src/delivery/feature.ts
CHANGED
|
@@ -64,8 +64,13 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
64
64
|
// apply-key is a registered domain-event (DELIVERY_ATTEMPT_EVENT).
|
|
65
65
|
// recipientAddress is the real PII (email address); recipientId stays
|
|
66
66
|
// plaintext — pseudonymous fk, same line as config.userId (#821).
|
|
67
|
+
// recipientId is nullable (system-triggered sends to non-users), so the
|
|
68
|
+
// address falls back to the tenant key instead of the plaintext it used
|
|
69
|
+
// to be — still shreddable, one tenant wide (fw#2776).
|
|
67
70
|
r.defineEvent("attempt", deliveryAttemptSchema, {
|
|
68
|
-
piiFields: {
|
|
71
|
+
piiFields: {
|
|
72
|
+
recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } },
|
|
73
|
+
},
|
|
69
74
|
});
|
|
70
75
|
|
|
71
76
|
// Inline projection that materialises every delivery attempt into
|
|
@@ -10,10 +10,16 @@ import {
|
|
|
10
10
|
createInMemoryFileProvider,
|
|
11
11
|
type InMemoryFileProvider,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
TestUsers,
|
|
17
|
+
unsafeCreateEntityTable,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
19
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
15
20
|
import { createConfigFeature } from "../../config";
|
|
16
21
|
import { createTenantFeature } from "../../tenant/feature";
|
|
22
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
17
23
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
18
24
|
import { documentIngestFoundationFeature } from "../feature";
|
|
19
25
|
|
|
@@ -48,6 +54,7 @@ beforeAll(async () => {
|
|
|
48
54
|
],
|
|
49
55
|
files: { storageProvider: provider },
|
|
50
56
|
});
|
|
57
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
51
58
|
});
|
|
52
59
|
|
|
53
60
|
afterAll(async () => {
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { createConfigFeature } from "../../config";
|
|
47
47
|
import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
|
|
48
48
|
import { createTenantFeature } from "../../tenant/feature";
|
|
49
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
49
50
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
50
51
|
import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
|
|
51
52
|
import { InboundMailFoundationHandlers, InboundMailFoundationQueries } from "../constants";
|
|
@@ -83,6 +84,7 @@ beforeAll(async () => {
|
|
|
83
84
|
});
|
|
84
85
|
db = stack.db;
|
|
85
86
|
await createEventsTable(db);
|
|
87
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
86
88
|
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
87
89
|
// Unmanaged direct-write stores — kein r.entity, kein Auto-Push.
|
|
88
90
|
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { createConfigFeature } from "../../config";
|
|
28
28
|
import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
|
|
29
29
|
import { createTenantFeature } from "../../tenant/feature";
|
|
30
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
30
31
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
31
32
|
import { inboundMessageAggregateId } from "../aggregate-id";
|
|
32
33
|
import { InboundMailFoundationHandlers } from "../constants";
|
|
@@ -51,6 +52,7 @@ beforeAll(async () => {
|
|
|
51
52
|
});
|
|
52
53
|
db = stack.db;
|
|
53
54
|
await createEventsTable(db);
|
|
55
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
54
56
|
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
55
57
|
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
56
58
|
await unsafeCreateEntityTable(db, seenMessageEntity);
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
seedInboundMessage,
|
|
34
34
|
} from "../../inbound-provider-inmemory/feature";
|
|
35
35
|
import { createTenantFeature } from "../../tenant/feature";
|
|
36
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
36
37
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
37
38
|
import {
|
|
38
39
|
createInboundMailSupervisor,
|
|
@@ -65,6 +66,7 @@ beforeAll(async () => {
|
|
|
65
66
|
});
|
|
66
67
|
db = stack.db;
|
|
67
68
|
await createEventsTable(db);
|
|
69
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
68
70
|
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
69
71
|
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
70
72
|
await unsafeCreateEntityTable(db, seenMessageEntity);
|