@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.321.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__/auth-tenants-rate-limit.integration.test.ts +3 -0
- package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
- package/src/auth-email-password/auth-paths.ts +10 -6
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-email-password/index.ts +1 -1
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
- package/src/auth-email-password/web/auth-client.ts +16 -1
- package/src/auth-email-password/web/auth-form-logic.ts +10 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
- package/src/auth-mfa/web/mfa-client.ts +18 -2
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/channel-email/smtp-transport.ts +7 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- package/src/file-derivatives/feature.ts +1 -1
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
- package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
- package/src/foundation-shared/index.ts +9 -0
- package/src/foundation-shared/mail-host-policy.ts +70 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
- package/src/inbound-provider-imap/changes.json +14 -1
- package/src/inbound-provider-imap/feature.ts +6 -3
- package/src/inbound-provider-imap/imap-client.ts +60 -2
- package/src/inbound-provider-imap/index.ts +1 -1
- package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
- package/src/ledger/changes.json +9 -1
- package/src/ledger/entity.ts +14 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
- package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
- package/src/mail-transport-smtp/changes.json +14 -1
- package/src/mail-transport-smtp/feature.ts +76 -1
- package/src/mail-transport-smtp/index.ts +6 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
- package/src/shared/password-hashing.test.ts +21 -13
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
- package/src/step-dispatcher/changes.json +21 -1
- package/src/step-dispatcher/feature.ts +63 -15
- package/src/step-dispatcher/index.ts +11 -2
- package/src/step-dispatcher/webhook-runner.ts +157 -31
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/changes.json +6 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
- package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +35 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.321.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>",
|
|
@@ -134,12 +134,12 @@
|
|
|
134
134
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
137
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.321.0",
|
|
138
|
+
"@cosmicdrift/kumiko-framework": "0.321.0",
|
|
139
|
+
"@cosmicdrift/kumiko-headless": "0.321.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer": "0.321.0",
|
|
141
|
+
"@cosmicdrift/kumiko-renderer-web": "0.321.0",
|
|
142
|
+
"@cosmicdrift/kumiko-types": "0.321.0",
|
|
143
143
|
"@mollie/api-client": "^4.5.0",
|
|
144
144
|
"@node-rs/argon2": "^2.0.2",
|
|
145
145
|
"@types/mailparser": "^3.4.6",
|
|
@@ -168,8 +168,8 @@
|
|
|
168
168
|
],
|
|
169
169
|
"devDependencies": {
|
|
170
170
|
"@testing-library/user-event": "^14.6.1",
|
|
171
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
172
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
171
|
+
"@cosmicdrift/kumiko-locale-de": "0.321.0",
|
|
172
|
+
"@cosmicdrift/kumiko-locale-es": "0.321.0",
|
|
173
173
|
"jsqr": "^1.4.0"
|
|
174
174
|
}
|
|
175
175
|
}
|
|
@@ -50,6 +50,9 @@ beforeAll(async () => {
|
|
|
50
50
|
rateLimit: {
|
|
51
51
|
auth: { onFailClosed: () => {} },
|
|
52
52
|
},
|
|
53
|
+
// Tests below assert the literal IP in the rate-limit bucket key, so the
|
|
54
|
+
// resolver needs a trusted hop to read x-forwarded-for at all.
|
|
55
|
+
trustedProxyHops: 1,
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// auth.postAuthLanding full-stack integration: login, signup-confirm, and
|
|
2
|
+
// the invite-accept branches all consult the SAME resolver and thread the
|
|
3
|
+
// same args through the real HTTP routes — plus the open-redirect / throw
|
|
4
|
+
// protections from post-auth-landing.ts, proven through the wire instead of
|
|
5
|
+
// just at the unit level.
|
|
6
|
+
//
|
|
7
|
+
// currentResolver is a module-local mutable slot behind one thin
|
|
8
|
+
// authConfig.postAuthLanding indirection — beforeEach resets it to an
|
|
9
|
+
// offlot-artige default rule; individual tests override it to capture args
|
|
10
|
+
// or exercise the safety rails.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import type {
|
|
14
|
+
PostAuthLandingArgs,
|
|
15
|
+
PostAuthLandingResolver,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
17
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
18
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import {
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
unsafeCreateEntityTable,
|
|
23
|
+
unsafePushTables,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
26
|
+
import { createConfigFeature } from "../../config";
|
|
27
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
28
|
+
import { configValuesTable } from "../../config/table";
|
|
29
|
+
import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
|
|
30
|
+
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
31
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
32
|
+
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
33
|
+
import { hashPassword } from "../../shared";
|
|
34
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
35
|
+
import { createTenantFeature } from "../../tenant";
|
|
36
|
+
import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
|
|
37
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
38
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
39
|
+
import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
|
|
40
|
+
import { createUserFeature } from "../../user/feature";
|
|
41
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
42
|
+
import { AuthHandlers } from "../constants";
|
|
43
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
44
|
+
import { seedUser } from "../seeding";
|
|
45
|
+
|
|
46
|
+
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
47
|
+
const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
|
|
48
|
+
|
|
49
|
+
const emailTransport = createInMemoryTransport();
|
|
50
|
+
|
|
51
|
+
let stack: TestStack;
|
|
52
|
+
|
|
53
|
+
// offlot-style default rule: invite routes by the inviting tenant (not the
|
|
54
|
+
// role); otherwise the highest role wins.
|
|
55
|
+
function defaultResolver(args: PostAuthLandingArgs): string | undefined {
|
|
56
|
+
if (args.flow === "invite") return `/a/invited/${args.tenantId}`;
|
|
57
|
+
if (args.roles.includes("SystemAdmin")) return "/a/waitlist-list";
|
|
58
|
+
if (args.roles.includes("TenantAdmin") || args.roles.includes("Admin"))
|
|
59
|
+
return "/a/vehicle-create";
|
|
60
|
+
return "/a/profile";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let currentResolver: PostAuthLandingResolver = defaultResolver;
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
stack = await setupTestStack({
|
|
67
|
+
features: [
|
|
68
|
+
createConfigFeature(),
|
|
69
|
+
createUserFeature(),
|
|
70
|
+
createTenantFeature(),
|
|
71
|
+
createTemplateResolverFeature(),
|
|
72
|
+
createRendererFoundationFeature(),
|
|
73
|
+
createDeliveryFeature(),
|
|
74
|
+
createRendererSimpleFeature(),
|
|
75
|
+
createChannelEmailFeature({
|
|
76
|
+
transport: emailTransport,
|
|
77
|
+
renderer: simpleRenderer,
|
|
78
|
+
resolveEmail: async () => "unused@test.local",
|
|
79
|
+
}),
|
|
80
|
+
createAuthEmailPasswordFeature({
|
|
81
|
+
signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
|
|
82
|
+
invite: { tokenTtlMinutes: 60, appUrl: APP_ACCEPT_URL },
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
extraContext: (deps) => ({
|
|
86
|
+
...createDeliveryTestContext(deps),
|
|
87
|
+
configResolver: createConfigResolver(),
|
|
88
|
+
}),
|
|
89
|
+
authConfig: {
|
|
90
|
+
membershipQuery: "tenant:query:memberships",
|
|
91
|
+
loginHandler: AuthHandlers.login,
|
|
92
|
+
signup: {
|
|
93
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
94
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
95
|
+
},
|
|
96
|
+
invite: {
|
|
97
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
98
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
99
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
100
|
+
},
|
|
101
|
+
postAuthLanding: (args) => currentResolver(args),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
106
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
107
|
+
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
108
|
+
await unsafePushTables(stack.db, {
|
|
109
|
+
configValuesTable,
|
|
110
|
+
tenantMembershipsTable,
|
|
111
|
+
notificationPreferencesTable,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterAll(async () => {
|
|
116
|
+
await stack.cleanup();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
currentResolver = defaultResolver;
|
|
121
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
122
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
123
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
|
|
124
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
125
|
+
emailTransport.sent.length = 0;
|
|
126
|
+
const signupKeys = await stack.redis.redis.keys("signup:*");
|
|
127
|
+
if (signupKeys.length > 0) await stack.redis.redis.del(...signupKeys);
|
|
128
|
+
const inviteKeys = await stack.redis.redis.keys("invite:*");
|
|
129
|
+
if (inviteKeys.length > 0) await stack.redis.redis.del(...inviteKeys);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function newTenantId(): TenantId {
|
|
133
|
+
return crypto.randomUUID() as TenantId;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function postLogin(email: string, password: string): Promise<Response> {
|
|
137
|
+
return stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function seedTenantAdmin(
|
|
141
|
+
email: string,
|
|
142
|
+
password: string,
|
|
143
|
+
roles: readonly string[],
|
|
144
|
+
): Promise<TenantId> {
|
|
145
|
+
const tenantId = newTenantId();
|
|
146
|
+
await seedTenant(stack.db, { id: tenantId, key: `tenant-${tenantId.slice(0, 8)}`, name: "T" });
|
|
147
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
148
|
+
email,
|
|
149
|
+
displayName: "Landing Test User",
|
|
150
|
+
passwordHash: await hashPassword(password),
|
|
151
|
+
emailVerified: true,
|
|
152
|
+
});
|
|
153
|
+
await seedTenantMembership(stack.db, { userId, tenantId, roles: [...roles] });
|
|
154
|
+
return tenantId;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function extractTokenFromMail(html: string): string {
|
|
158
|
+
const match = html.match(/[?&]token=([^&"'<\s]+)/);
|
|
159
|
+
if (!match?.[1]) throw new Error(`No token in mail html: ${html.slice(0, 200)}`);
|
|
160
|
+
return decodeURIComponent(match[1]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function requestSignupAndCaptureToken(email: string): Promise<string> {
|
|
164
|
+
emailTransport.sent.length = 0;
|
|
165
|
+
const res = await stack.http.raw("POST", "/api/auth/signup-request", { email });
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
const sent = emailTransport.sent[0];
|
|
168
|
+
if (!sent) throw new Error("signup-request fixture didn't send mail");
|
|
169
|
+
return extractTokenFromMail(sent.html);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function aliceSession(): Promise<{ session: SessionUser; tenantId: TenantId }> {
|
|
173
|
+
const tenantId = newTenantId();
|
|
174
|
+
await seedTenant(stack.db, {
|
|
175
|
+
id: tenantId,
|
|
176
|
+
key: `tenant-alice-${tenantId.slice(0, 8)}`,
|
|
177
|
+
name: "A",
|
|
178
|
+
});
|
|
179
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
180
|
+
email: "alice-landing@example.com",
|
|
181
|
+
displayName: "Alice",
|
|
182
|
+
passwordHash: await hashPassword("alice-landing-pw-1234"),
|
|
183
|
+
emailVerified: true,
|
|
184
|
+
});
|
|
185
|
+
await seedTenantMembership(stack.db, { userId, tenantId, roles: ["Admin"] });
|
|
186
|
+
return { session: { id: userId, tenantId, roles: ["Admin"] }, tenantId };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function inviteEmail(admin: SessionUser, email: string, role: string): Promise<string> {
|
|
190
|
+
await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, admin);
|
|
191
|
+
const sent = emailTransport.sent.at(-1);
|
|
192
|
+
if (!sent) throw new Error("invite-create didn't send a mail");
|
|
193
|
+
return extractTokenFromMail(sent.html);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe("login", () => {
|
|
197
|
+
test("TenantAdmin role → /a/vehicle-create", async () => {
|
|
198
|
+
await seedTenantAdmin("landing-tenantadmin@example.com", "landing-pw-1234", ["TenantAdmin"]);
|
|
199
|
+
const res = await postLogin("landing-tenantadmin@example.com", "landing-pw-1234");
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
const body = (await res.json()) as { landingPath?: string };
|
|
202
|
+
expect(body.landingPath).toBe("/a/vehicle-create");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("plain User role → /a/profile", async () => {
|
|
206
|
+
await seedTenantAdmin("landing-user@example.com", "landing-pw-1234", ["User"]);
|
|
207
|
+
const res = await postLogin("landing-user@example.com", "landing-pw-1234");
|
|
208
|
+
expect(res.status).toBe(200);
|
|
209
|
+
const body = (await res.json()) as { landingPath?: string };
|
|
210
|
+
expect(body.landingPath).toBe("/a/profile");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("signup-confirm", () => {
|
|
215
|
+
test("INITIAL_SIGNUP_ROLES path with tenantKey passed to resolver", async () => {
|
|
216
|
+
let receivedArgs: PostAuthLandingArgs | undefined;
|
|
217
|
+
currentResolver = (args) => {
|
|
218
|
+
receivedArgs = args;
|
|
219
|
+
return defaultResolver(args);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const token = await requestSignupAndCaptureToken("landing-signup@example.com");
|
|
223
|
+
const res = await stack.http.raw("POST", "/api/auth/signup-confirm", {
|
|
224
|
+
token,
|
|
225
|
+
password: "landing-signup-pw-1234",
|
|
226
|
+
});
|
|
227
|
+
expect(res.status).toBe(200);
|
|
228
|
+
const body = (await res.json()) as { tenantKey?: string; landingPath?: string };
|
|
229
|
+
|
|
230
|
+
// INITIAL_SIGNUP_ROLES grants TenantAdmin → same branch as the login test.
|
|
231
|
+
expect(body.landingPath).toBe("/a/vehicle-create");
|
|
232
|
+
expect(receivedArgs?.flow).toBe("signup");
|
|
233
|
+
expect(receivedArgs?.tenantKey).toBe(body.tenantKey);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("invite", () => {
|
|
238
|
+
test("invite-signup-complete (Branch 3) → /a/invited/<tenantId>", async () => {
|
|
239
|
+
const { session } = await aliceSession();
|
|
240
|
+
const token = await inviteEmail(session, "landing-invite-new@example.com", "Editor");
|
|
241
|
+
|
|
242
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
243
|
+
token,
|
|
244
|
+
password: "landing-invite-pw-1234",
|
|
245
|
+
});
|
|
246
|
+
expect(res.status).toBe(200);
|
|
247
|
+
const body = (await res.json()) as { tenantId?: string; landingPath?: string };
|
|
248
|
+
expect(body.landingPath).toBe(`/a/invited/${body.tenantId}`);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("invite-accept-with-login (Branch 2) → /a/invited/<tenantId> — uses the invited tenant, not the acceptor's own", async () => {
|
|
252
|
+
const { session } = await aliceSession();
|
|
253
|
+
const bobTenantId = await seedTenantAdmin(
|
|
254
|
+
"landing-invite-bob@example.com",
|
|
255
|
+
"landing-bob-pw-1234",
|
|
256
|
+
["User"],
|
|
257
|
+
);
|
|
258
|
+
const token = await inviteEmail(session, "landing-invite-bob@example.com", "Editor");
|
|
259
|
+
|
|
260
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
261
|
+
token,
|
|
262
|
+
email: "landing-invite-bob@example.com",
|
|
263
|
+
password: "landing-bob-pw-1234",
|
|
264
|
+
});
|
|
265
|
+
expect(res.status).toBe(200);
|
|
266
|
+
const body = (await res.json()) as { tenantId?: string; landingPath?: string };
|
|
267
|
+
expect(body.tenantId).not.toBe(bobTenantId);
|
|
268
|
+
expect(body.landingPath).toBe(`/a/invited/${body.tenantId}`);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("open-redirect / throwing resolver protections", () => {
|
|
273
|
+
const unsafeCandidates: ReadonlyArray<[string, string]> = [
|
|
274
|
+
["protocol-relative", "//evil.com"],
|
|
275
|
+
["absolute URL", "https://evil.com"],
|
|
276
|
+
["backslash", "/\\evil.com"],
|
|
277
|
+
["empty string", ""],
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
for (const [label, candidate] of unsafeCandidates) {
|
|
281
|
+
test(`${label} candidate → login stays 200 with token, landingPath omitted`, async () => {
|
|
282
|
+
currentResolver = () => candidate;
|
|
283
|
+
await seedTenantAdmin("landing-unsafe@example.com", "landing-unsafe-pw-1234", ["User"]);
|
|
284
|
+
|
|
285
|
+
const res = await postLogin("landing-unsafe@example.com", "landing-unsafe-pw-1234");
|
|
286
|
+
expect(res.status).toBe(200);
|
|
287
|
+
const body = (await res.json()) as { token?: string; landingPath?: string };
|
|
288
|
+
expect(body.token).toBeTruthy();
|
|
289
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("kumiko_auth=");
|
|
290
|
+
expect(body).not.toHaveProperty("landingPath");
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
test("rejecting async resolver → login stays 200 with token, landingPath omitted", async () => {
|
|
295
|
+
// @cast-boundary test — simulates a JS consumer bypassing the sync signature
|
|
296
|
+
currentResolver = (async () => {
|
|
297
|
+
throw new Error("boom");
|
|
298
|
+
}) as unknown as PostAuthLandingResolver;
|
|
299
|
+
await seedTenantAdmin("landing-async@example.com", "landing-async-pw-1234", ["User"]);
|
|
300
|
+
|
|
301
|
+
const res = await postLogin("landing-async@example.com", "landing-async-pw-1234");
|
|
302
|
+
expect(res.status).toBe(200);
|
|
303
|
+
const body = (await res.json()) as { token?: string; landingPath?: string };
|
|
304
|
+
expect(body.token).toBeTruthy();
|
|
305
|
+
expect(body).not.toHaveProperty("landingPath");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("throwing resolver → login stays 200 with token, landingPath omitted", async () => {
|
|
309
|
+
currentResolver = () => {
|
|
310
|
+
throw new Error("boom");
|
|
311
|
+
};
|
|
312
|
+
await seedTenantAdmin("landing-throw@example.com", "landing-throw-pw-1234", ["User"]);
|
|
313
|
+
|
|
314
|
+
const res = await postLogin("landing-throw@example.com", "landing-throw-pw-1234");
|
|
315
|
+
expect(res.status).toBe(200);
|
|
316
|
+
const body = (await res.json()) as { token?: string; landingPath?: string };
|
|
317
|
+
expect(body.token).toBeTruthy();
|
|
318
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("kumiko_auth=");
|
|
319
|
+
expect(body).not.toHaveProperty("landingPath");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -96,6 +96,10 @@ beforeAll(async () => {
|
|
|
96
96
|
rateLimit: {
|
|
97
97
|
auth: { limit: 2, windowSeconds: 60, onFailClosed: () => {} },
|
|
98
98
|
},
|
|
99
|
+
// Tests below rely on distinct per-test IPs to keep buckets from
|
|
100
|
+
// cross-contaminating, so the resolver needs a trusted hop to read
|
|
101
|
+
// x-forwarded-for at all.
|
|
102
|
+
trustedProxyHops: 1,
|
|
99
103
|
});
|
|
100
104
|
|
|
101
105
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
@@ -115,6 +115,10 @@ beforeAll(async () => {
|
|
|
115
115
|
sessionCreator: callbacks.sessionCreator,
|
|
116
116
|
sessionRevoker: callbacks.sessionRevoker,
|
|
117
117
|
},
|
|
118
|
+
// "login wires into sessionCreator" below asserts the literal ip stored
|
|
119
|
+
// by sessionCreator, so the resolver needs a trusted hop to read
|
|
120
|
+
// x-forwarded-for at all.
|
|
121
|
+
trustedProxyHops: 1,
|
|
118
122
|
});
|
|
119
123
|
|
|
120
124
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
@@ -149,6 +149,15 @@ beforeAll(async () => {
|
|
|
149
149
|
confirmHandler: AuthHandlers.signupConfirm,
|
|
150
150
|
},
|
|
151
151
|
},
|
|
152
|
+
// signup-request declares rateLimit: { per: "ip+handler", limit: 10 }.
|
|
153
|
+
// Since e7bf543ad, an unset socket address resolves to the shared
|
|
154
|
+
// "unknown" IP rather than skipping the bucket — every postSignupRequest
|
|
155
|
+
// call in this file (14+ across all tests) would otherwise collapse
|
|
156
|
+
// into that one 10/60s bucket and fail later tests with rate_limited,
|
|
157
|
+
// regardless of the test's own scenario. trustedProxyHops: 1 lets
|
|
158
|
+
// postSignupRequest hand each distinct signup its own resolved IP via
|
|
159
|
+
// X-Forwarded-For, mirroring distinct real callers instead of one.
|
|
160
|
+
trustedProxyHops: 1,
|
|
152
161
|
});
|
|
153
162
|
|
|
154
163
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
@@ -186,7 +195,15 @@ async function postSignupRequest(
|
|
|
186
195
|
email: string,
|
|
187
196
|
headers?: Record<string, string>,
|
|
188
197
|
): Promise<Response> {
|
|
189
|
-
|
|
198
|
+
// Distinct email → distinct resolved IP (trustedProxyHops: 1 above), so
|
|
199
|
+
// each test's own signup-request calls land in their own rate-limit
|
|
200
|
+
// bucket instead of sharing the file-wide "unknown" one.
|
|
201
|
+
return stack.http.raw(
|
|
202
|
+
"POST",
|
|
203
|
+
"/api/auth/signup-request",
|
|
204
|
+
{ email },
|
|
205
|
+
{ "x-forwarded-for": email, ...headers },
|
|
206
|
+
);
|
|
190
207
|
}
|
|
191
208
|
|
|
192
209
|
async function postSignupConfirm(token: string, password: string): Promise<Response> {
|
|
@@ -273,6 +290,9 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
273
290
|
expect(body.user?.tenantId).toBeTruthy();
|
|
274
291
|
expect(body.user?.roles).toContain("TenantAdmin");
|
|
275
292
|
expect(body.tenantKey).toBeTruthy();
|
|
293
|
+
// No auth.postAuthLanding configured on this stack — the field must be
|
|
294
|
+
// absent entirely, not `landingPath: undefined`.
|
|
295
|
+
expect(body).not.toHaveProperty("landingPath");
|
|
276
296
|
|
|
277
297
|
// Cookies gesetzt (auth + csrf)
|
|
278
298
|
const setCookies = confirmRes.headers.get("set-cookie") ?? "";
|
|
@@ -143,6 +143,11 @@ beforeAll(async () => {
|
|
|
143
143
|
requestHandler: AuthHandlers.signupRequest,
|
|
144
144
|
confirmHandler: AuthHandlers.signupConfirm,
|
|
145
145
|
},
|
|
146
|
+
// Pins that a claimed handover grant reaches the resolver's `handover`
|
|
147
|
+
// arg — the offlot-style rule this mirrors routes a claimed run to a
|
|
148
|
+
// dedicated landing page, everything else to the generic profile.
|
|
149
|
+
postAuthLanding: ({ handover }) =>
|
|
150
|
+
handover !== undefined ? `/a/run/${encodeURIComponent(handover.id)}` : "/a/profile",
|
|
146
151
|
},
|
|
147
152
|
});
|
|
148
153
|
|
|
@@ -273,11 +278,13 @@ describe("cross-device try-first handover :: signup-request + signup-confirm", (
|
|
|
273
278
|
const body = (await confirmRes.json()) as {
|
|
274
279
|
user?: { id: string; tenantId: string };
|
|
275
280
|
handover?: { entityType: string; id: string };
|
|
281
|
+
landingPath?: string;
|
|
276
282
|
};
|
|
277
283
|
const destinationTenantId = body.user?.tenantId;
|
|
278
284
|
if (!destinationTenantId) throw new Error("confirm did not return a tenantId");
|
|
279
285
|
|
|
280
286
|
expect(body.handover).toEqual({ entityType: "run", id: runId });
|
|
287
|
+
expect(body.landingPath).toBe(`/a/run/${runId}`);
|
|
281
288
|
expect(await readTenantId("signup_handover_run", runId)).toBe(destinationTenantId);
|
|
282
289
|
expect(await readTenantId("signup_handover_photo", photoId)).toBe(destinationTenantId);
|
|
283
290
|
|
|
@@ -320,10 +327,13 @@ describe("cross-device try-first handover :: signup-request + signup-confirm", (
|
|
|
320
327
|
token?: string;
|
|
321
328
|
user?: { id: string; tenantId: string };
|
|
322
329
|
handover?: { entityType: string; id: string };
|
|
330
|
+
landingPath?: string;
|
|
323
331
|
};
|
|
324
332
|
expect(body.isSuccess).toBe(true);
|
|
325
333
|
expect(body.token).toBeTruthy();
|
|
326
334
|
expect(body.handover).toBeUndefined();
|
|
335
|
+
// No claim landed → resolver's `handover` arg is undefined → generic path.
|
|
336
|
+
expect(body.landingPath).toBe("/a/profile");
|
|
327
337
|
|
|
328
338
|
// Account/login for the signup still works — a failed claim redeem
|
|
329
339
|
// must never fail the signup itself.
|
|
@@ -2,22 +2,26 @@
|
|
|
2
2
|
// resolveAuthMail builds each flow's magic-link appUrl from baseUrl + these;
|
|
3
3
|
// the handlers append `?token=…` and mail via delivery (ctx.notify).
|
|
4
4
|
|
|
5
|
+
/** A plain path, or a function picking the path per resolved mail locale
|
|
6
|
+
* (e.g. `/en/reset-password`, `/de/reset-password`). */
|
|
7
|
+
export type AuthPath = string | ((locale: string) => string);
|
|
8
|
+
|
|
5
9
|
/** Pfad-Konstanten der 4 Auth-Seiten (relativ zur App-baseUrl). */
|
|
6
10
|
export type AuthPaths = {
|
|
7
|
-
readonly resetPassword:
|
|
8
|
-
readonly verifyEmail:
|
|
9
|
-
readonly signupComplete:
|
|
10
|
-
readonly inviteAccept:
|
|
11
|
+
readonly resetPassword: AuthPath;
|
|
12
|
+
readonly verifyEmail: AuthPath;
|
|
13
|
+
readonly signupComplete: AuthPath;
|
|
14
|
+
readonly inviteAccept: AuthPath;
|
|
11
15
|
};
|
|
12
16
|
|
|
13
17
|
/** Konventions-Pfade — alle Kumiko-Apps nutzen dieselben. Apps überschreiben
|
|
14
18
|
* nur die Ausnahme via `makeAuthPaths({ ... })`. */
|
|
15
|
-
export const DEFAULT_AUTH_PATHS
|
|
19
|
+
export const DEFAULT_AUTH_PATHS = {
|
|
16
20
|
resetPassword: "/reset-password",
|
|
17
21
|
verifyEmail: "/verify-email",
|
|
18
22
|
signupComplete: "/signup/complete",
|
|
19
23
|
inviteAccept: "/invite/accept",
|
|
20
|
-
};
|
|
24
|
+
} as const satisfies AuthPaths;
|
|
21
25
|
|
|
22
26
|
export function makeAuthPaths(overrides: Partial<AuthPaths> = {}): AuthPaths {
|
|
23
27
|
return { ...DEFAULT_AUTH_PATHS, ...overrides };
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "AuthPaths entries accept a locale function, not just a plain path",
|
|
6
|
+
"detail": "AuthPaths (and DEFAULT_AUTH_PATHS/makeAuthPaths) previously typed every entry as a plain string, so an app needing a locale-in-path reset link (/de/reset-password) had to set auth.passwordReset explicitly instead of auth.mail.paths — and since that block requires its own hmacSecret, the app ended up threading its own secret instead of the one auth.mail resolves, decoupling reset-token signing from that secret's rotation. Each AuthPaths entry now accepts a plain path or a (locale) => path function, mirroring the appUrl shape every flow's options already take, so a locale-aware reset link needs only auth.mail.paths.resetPassword, never a hand-rolled passwordReset block."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.320.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "auth routes return a server-computed landingPath from auth.postAuthLanding",
|
|
12
|
+
"detail": "`AuthRoutesConfig.postAuthLanding` lets an app resolve, in one server-side place, where a user lands after auth instead of every frontend screen re-deriving it from roles/tenantId. The resolver runs for login, mfa-verify, mfa-preauth-confirm, signup-confirm, and all three invite-accept branches, and its result is validated (root-relative only, no protocol-relative/backslash/control-character paths, no cross-origin resolution) before it is ever added to the response as `landingPath`. An invalid path or a throwing resolver just omits the field — auth never fails because of it.\n`run-prod-app`'s and `run-dev-app`'s auth options now accept `postAuthLanding` and thread it through to the framework config unchanged.\n`SignupCompleteScreen` and `InviteAcceptScreen` now prefer the server's `landingPath` over their `loggedInHref` prop, which becomes a deprecated per-app fallback for apps that haven't configured a resolver yet.\nCloses #3320."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.314.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// hashPassword/verifyPassword; removing it is a breaking change that needs
|
|
7
7
|
// its own changeset + deliberate deprecation window, not a silent drop.
|
|
8
8
|
export { hashPassword, verifyPassword } from "../shared/password-hashing";
|
|
9
|
-
export { type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
|
|
9
|
+
export { type AuthPath, type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
|
|
10
10
|
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants";
|
|
11
11
|
// Renderers for the auth mails. All four magic-link flows (reset, verify,
|
|
12
12
|
// signup-activation, invite) emit structured AuthMailContent through delivery
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
passwordPairIssue,
|
|
4
|
+
resolveLoggedInHref,
|
|
5
|
+
resolvePostAuthHref,
|
|
6
|
+
retryAfterMinutes,
|
|
7
|
+
} from "../auth-form-logic";
|
|
3
8
|
|
|
4
9
|
describe("passwordPairIssue", () => {
|
|
5
10
|
test("too_short when under min length", () => {
|
|
@@ -71,3 +76,31 @@ describe("resolveLoggedInHref", () => {
|
|
|
71
76
|
).toBe("/t1/Editor");
|
|
72
77
|
});
|
|
73
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("resolvePostAuthHref", () => {
|
|
81
|
+
type SignupArgs = { tenantKey: string; roles: readonly string[] };
|
|
82
|
+
|
|
83
|
+
test("server landingPath wins over loggedInHref", () => {
|
|
84
|
+
expect(
|
|
85
|
+
resolvePostAuthHref<SignupArgs>("/a/vehicle-create", "/", {
|
|
86
|
+
tenantKey: "acme",
|
|
87
|
+
roles: ["User"],
|
|
88
|
+
}),
|
|
89
|
+
).toBe("/a/vehicle-create");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("undefined landingPath falls back to loggedInHref (string form)", () => {
|
|
93
|
+
expect(
|
|
94
|
+
resolvePostAuthHref<SignupArgs>(undefined, "/fixed", { tenantKey: "acme", roles: [] }),
|
|
95
|
+
).toBe("/fixed");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("undefined landingPath falls back to loggedInHref (function form)", () => {
|
|
99
|
+
expect(
|
|
100
|
+
resolvePostAuthHref<SignupArgs>(undefined, ({ tenantKey }) => `/${tenantKey}/`, {
|
|
101
|
+
tenantKey: "acme",
|
|
102
|
+
roles: [],
|
|
103
|
+
}),
|
|
104
|
+
).toBe("/acme/");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -121,6 +121,36 @@ describe("InviteAcceptScreen — logged-in branch", () => {
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test("server landingPath gewinnt über loggedInHref", async () => {
|
|
125
|
+
globalThis.fetch = mock(
|
|
126
|
+
async () =>
|
|
127
|
+
new Response(
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
tenantId: "tenant-new",
|
|
130
|
+
role: "Dealer",
|
|
131
|
+
alreadyMember: false,
|
|
132
|
+
landingPath: "/a/vehicle-create",
|
|
133
|
+
}),
|
|
134
|
+
{ status: 200 },
|
|
135
|
+
),
|
|
136
|
+
) as unknown as typeof fetch;
|
|
137
|
+
const assign = mock<(url: string) => void>();
|
|
138
|
+
Object.defineProperty(window, "location", {
|
|
139
|
+
configurable: true,
|
|
140
|
+
writable: true,
|
|
141
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
renderWithProviders(
|
|
145
|
+
<InviteAcceptScreen token="tok-123" loggedInHref={({ tenantId }) => `/${tenantId}/home`} />,
|
|
146
|
+
{ session: makeSessionApi({ status: "authenticated" }) },
|
|
147
|
+
);
|
|
148
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
expect(assign).toHaveBeenCalledWith("/a/vehicle-create");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
124
154
|
test("String-Form bleibt unverändert gültig", async () => {
|
|
125
155
|
const assign = mock<(url: string) => void>();
|
|
126
156
|
Object.defineProperty(window, "location", {
|
|
@@ -245,6 +245,32 @@ describe("SignupCompleteScreen", () => {
|
|
|
245
245
|
expect(receivedArgs).toStrictEqual({ tenantKey: "acme", roles: ["User"] });
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
test("server landingPath gewinnt über loggedInHref", async () => {
|
|
249
|
+
globalThis.fetch = mock(
|
|
250
|
+
async () =>
|
|
251
|
+
new Response(
|
|
252
|
+
JSON.stringify({
|
|
253
|
+
user: { id: "u1", tenantId: "t1", roles: ["User"] },
|
|
254
|
+
tenantKey: "acme",
|
|
255
|
+
landingPath: "/a/vehicle-create",
|
|
256
|
+
}),
|
|
257
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
258
|
+
),
|
|
259
|
+
) as unknown as typeof fetch;
|
|
260
|
+
|
|
261
|
+
renderWithProviders(
|
|
262
|
+
<SignupCompleteScreen token="abc-token" loggedInHref={({ tenantKey }) => `/${tenantKey}/`} />,
|
|
263
|
+
);
|
|
264
|
+
fillPasswords("validpass1", "validpass1");
|
|
265
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
266
|
+
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
269
|
+
"/a/vehicle-create",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
248
274
|
test("mismatch → client-side error, kein fetch-Call", async () => {
|
|
249
275
|
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
250
276
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|