@cosmicdrift/kumiko-bundled-features 0.318.0 → 0.320.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 +10 -10
- 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/constants.ts +44 -44
- 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-foundation.integration.test.ts +76 -4
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +235 -1
- package/src/billing-foundation/__tests__/checkout-core.test.ts +4 -0
- package/src/billing-foundation/__tests__/feature.test.ts +20 -0
- package/src/billing-foundation/changes.json +36 -0
- package/src/billing-foundation/checkout-core.ts +163 -40
- package/src/billing-foundation/entities.ts +3 -1
- package/src/billing-foundation/events.ts +5 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +6 -0
- package/src/billing-foundation/handlers/billing-plans.query.ts +3 -3
- package/src/billing-foundation/handlers/create-portal-session.write.ts +9 -18
- package/src/billing-foundation/handlers/process-event.write.ts +2 -0
- package/src/billing-foundation/handlers/start-plan-checkout.write.ts +8 -8
- package/src/billing-foundation/handlers/switch-plan.write.ts +9 -16
- package/src/billing-foundation/i18n.ts +4 -0
- package/src/billing-foundation/index.ts +18 -11
- package/src/billing-foundation/plan-catalog.ts +26 -12
- package/src/billing-foundation/projection.ts +31 -6
- package/src/billing-foundation/types.ts +32 -4
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +22 -9
- package/src/billing-foundation/web/billing-plans-panel.tsx +51 -9
- package/src/billing-foundation/web/index.ts +15 -0
- package/src/billing-foundation/webhook-handler.ts +1 -0
- package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
- package/src/channel-email/smtp-transport.ts +7 -0
- 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__/webhook-runner.test.ts +131 -0
- package/src/step-dispatcher/changes.json +14 -1
- package/src/step-dispatcher/feature.ts +16 -1
- package/src/step-dispatcher/index.ts +8 -1
- package/src/step-dispatcher/webhook-runner.ts +104 -18
- package/src/subscription-stripe/__tests__/feature.test.ts +20 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +24 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +20 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +37 -0
- package/src/subscription-stripe/changes.json +12 -0
- package/src/subscription-stripe/feature.ts +25 -0
- package/src/subscription-stripe/plugin-methods.ts +12 -4
- package/src/subscription-stripe/verify-webhook.ts +17 -0
- 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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Pure string
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// Pure string constants — no DB/Node builtins. Marked `@runtime client` so
|
|
3
|
+
// browser code (Members screen etc.) can import them too without the
|
|
4
|
+
// runtime-isolation guard complaining. Runtime may import "client" files
|
|
5
|
+
// (see RUNTIME_RULES), so server-side access (handlers, dispatcher) stays
|
|
6
|
+
// intact as well.
|
|
7
7
|
export const AUTH_EMAIL_PASSWORD_FEATURE = "auth-email-password" as const;
|
|
8
8
|
|
|
9
9
|
// Minimum length for reset/verify hmacSecret — mirrors the ≥32-char
|
|
@@ -25,20 +25,20 @@ export const AuthHandlers = {
|
|
|
25
25
|
// Redis lockout state.
|
|
26
26
|
requestAccountUnlock: "auth-email-password:write:request-account-unlock",
|
|
27
27
|
confirmAccountUnlock: "auth-email-password:write:confirm-account-unlock",
|
|
28
|
-
// Magic-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// user + tenant + Admin
|
|
32
|
-
//
|
|
28
|
+
// Magic-link self-signup (pre-activation-token pattern). Request mints an
|
|
29
|
+
// opaque random token, stores it bidirectionally in Redis and sends an
|
|
30
|
+
// activation email. Confirm redeems the token and creates
|
|
31
|
+
// user + tenant + Admin membership atomically. emailVerified=true from
|
|
32
|
+
// second 0 — clicking the mail link IS the proof.
|
|
33
33
|
signupRequest: "auth-email-password:write:signup-request",
|
|
34
34
|
signupConfirm: "auth-email-password:write:signup-confirm",
|
|
35
|
-
// Tenant-
|
|
36
|
-
//
|
|
37
|
-
// inviteCreate:
|
|
38
|
-
// inviteAccept: logged-in
|
|
39
|
-
// inviteAcceptWithLogin: anon
|
|
40
|
-
// inviteSignupComplete: anon
|
|
41
|
-
// inviteCancel:
|
|
35
|
+
// Tenant-invite magic-link (admin invites a user into an existing tenant).
|
|
36
|
+
// Three separate accept endpoints for clear branch separation:
|
|
37
|
+
// inviteCreate: admin → POST email + role
|
|
38
|
+
// inviteAccept: logged-in user → POST token (membership-add)
|
|
39
|
+
// inviteAcceptWithLogin: anon user with existing email → POST token + email + password
|
|
40
|
+
// inviteSignupComplete: anon user with new email → POST token + password
|
|
41
|
+
// inviteCancel: admin cancels a pending invite
|
|
42
42
|
inviteCreate: "auth-email-password:write:invite-create",
|
|
43
43
|
inviteAccept: "auth-email-password:write:invite-accept",
|
|
44
44
|
inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
|
|
@@ -76,20 +76,20 @@ export const AuthErrors = {
|
|
|
76
76
|
// code so a probing client can't distinguish tampered from stale.
|
|
77
77
|
invalidUnlockToken: "invalid_unlock_token",
|
|
78
78
|
unlockNotConfigured: "account_unlock_not_configured",
|
|
79
|
-
// Self-
|
|
80
|
-
//
|
|
81
|
-
// anti-enumeration-
|
|
79
|
+
// Self-signup: all confirm failures (unknown token, already
|
|
80
|
+
// consumed, expired) collapse onto this code — same
|
|
81
|
+
// anti-enumeration trade-off as reset/verify.
|
|
82
82
|
invalidSignupToken: "invalid_signup_token",
|
|
83
83
|
signupNotConfigured: "signup_not_configured",
|
|
84
|
-
// Self-
|
|
85
|
-
//
|
|
86
|
-
// anti-enumeration
|
|
87
|
-
//
|
|
88
|
-
//
|
|
84
|
+
// Self-signup: confirm rejects an already-registered email instead of
|
|
85
|
+
// reusing the existing user (account takeover, #365). NO
|
|
86
|
+
// anti-enumeration collapse like invalidSignupToken: whoever gets here
|
|
87
|
+
// controls the inbox (has the magic link), so revealing "email exists"
|
|
88
|
+
// is not new information.
|
|
89
89
|
signupEmailAlreadyRegistered: "signup_email_already_registered",
|
|
90
|
-
// Invite
|
|
91
|
-
// (anti-enumeration). emailMismatch
|
|
92
|
-
//
|
|
90
|
+
// Invite flow: all token failures collapse onto invalidInviteToken
|
|
91
|
+
// (anti-enumeration). emailMismatch when the invitee tries to accept the
|
|
92
|
+
// link with a different email than the one invited.
|
|
93
93
|
invalidInviteToken: "invalid_invite_token",
|
|
94
94
|
inviteEmailMismatch: "invite_email_mismatch",
|
|
95
95
|
inviteAlreadyMember: "invite_already_member",
|
|
@@ -100,17 +100,17 @@ export const AuthErrors = {
|
|
|
100
100
|
// deliberate enumeration trade-off: the lockout event itself is already
|
|
101
101
|
// observable to the attacker, and legit users benefit from a clear signal.
|
|
102
102
|
accountLocked: "account_locked",
|
|
103
|
-
// S2.U6 (
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// user-
|
|
103
|
+
// S2.U6 (GDPR Art. 18) — account is in Restricted status. Login is
|
|
104
|
+
// explicitly refused with its own code (not collapsed into
|
|
105
|
+
// invalid_credentials) so the UI can say "account is currently paused,
|
|
106
|
+
// click here to lift it". Enumeration leak accepted: restriction is
|
|
107
|
+
// user-initiated, the user already knows their account is restricted.
|
|
108
108
|
accountRestricted: "account_restricted",
|
|
109
|
-
// Account
|
|
110
|
-
// Restricted
|
|
111
|
-
// invalid_credentials
|
|
112
|
-
//
|
|
113
|
-
//
|
|
109
|
+
// Account is in DeletionRequested or Deleted status. Unlike
|
|
110
|
+
// Restricted, this is not reversible via login → we collapse onto
|
|
111
|
+
// invalid_credentials so the forget-path isn't enumerable via login
|
|
112
|
+
// (a user who clicked "delete account" shouldn't see again that their
|
|
113
|
+
// email address still exists in the DB).
|
|
114
114
|
} as const;
|
|
115
115
|
|
|
116
116
|
// Account-lockout defaults — overridable via
|
|
@@ -132,12 +132,12 @@ export const AUTH_RESET_DEFAULT_TTL_MINUTES = 15;
|
|
|
132
132
|
// replaying the same token re-sets the same flag.
|
|
133
133
|
export const AUTH_VERIFY_DEFAULT_TTL_MINUTES = 24 * 60;
|
|
134
134
|
|
|
135
|
-
// Self-
|
|
136
|
-
// "
|
|
137
|
-
//
|
|
135
|
+
// Self-signup: 24h default. Long enough that the user doesn't have to
|
|
136
|
+
// think "activate quickly" — a mail link that still works tomorrow morning
|
|
137
|
+
// is user-friendly. Shorter TTLs cause resend-spam because users forget.
|
|
138
138
|
export const AUTH_SIGNUP_DEFAULT_TTL_MINUTES = 24 * 60;
|
|
139
139
|
|
|
140
|
-
// Tenant
|
|
141
|
-
// Slack); invitees
|
|
142
|
-
//
|
|
140
|
+
// Tenant invite: 7 days default. Industry standard (GitHub, Linear,
|
|
141
|
+
// Slack); invitees often need longer to respond than in self-signup,
|
|
142
|
+
// where the user's intent is fresh.
|
|
143
143
|
export const AUTH_INVITE_DEFAULT_TTL_MINUTES = 7 * 24 * 60;
|
|
@@ -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;
|
|
@@ -33,6 +33,9 @@ export type LoginResponse = {
|
|
|
33
33
|
readonly tenantId: string;
|
|
34
34
|
readonly roles: readonly string[];
|
|
35
35
|
};
|
|
36
|
+
// Present only when the server's auth.postAuthLanding resolver returned a
|
|
37
|
+
// valid path — see auth-routes.ts. Screens prefer this over loggedInHref.
|
|
38
|
+
readonly landingPath?: string;
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
// Reason codes the login-handler is known to emit today (login-screen.tsx's
|
|
@@ -93,6 +96,14 @@ export type LoginResult =
|
|
|
93
96
|
| { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string }
|
|
94
97
|
| { readonly kind: "failure"; readonly error: LoginFailure };
|
|
95
98
|
|
|
99
|
+
function toLoginResponse(
|
|
100
|
+
token: string,
|
|
101
|
+
user: LoginResponse["user"],
|
|
102
|
+
landingPath: unknown,
|
|
103
|
+
): LoginResponse {
|
|
104
|
+
return typeof landingPath === "string" ? { token, user, landingPath } : { token, user };
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
// POST /api/auth/login. Success → token + user; MFA-enrolled user → a
|
|
97
108
|
// challenge token the caller completes via auth-mfa's verify screen;
|
|
98
109
|
// unenrolled user blocked by enforcement policy → mfa-setup-required;
|
|
@@ -118,6 +129,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
|
|
|
118
129
|
challengeToken?: string;
|
|
119
130
|
mfaSetupRequired?: boolean;
|
|
120
131
|
preauthSetupToken?: string;
|
|
132
|
+
landingPath?: string;
|
|
121
133
|
error?:
|
|
122
134
|
| {
|
|
123
135
|
code?: string;
|
|
@@ -140,7 +152,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
|
|
|
140
152
|
return { kind: "failure", error: { reason: "mfa_setup_required" } };
|
|
141
153
|
}
|
|
142
154
|
if (body.token !== undefined && body.user !== undefined) {
|
|
143
|
-
return { kind: "success", data:
|
|
155
|
+
return { kind: "success", data: toLoginResponse(body.token, body.user, body.landingPath) };
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
// Der Server schickt error entweder als string ("invalid_body") oder als
|
|
@@ -343,6 +355,9 @@ export type SignupConfirmSuccess = {
|
|
|
343
355
|
// Present only when a bound handover grant existed and its claim
|
|
344
356
|
// succeeded — see signup-confirm.write.ts.
|
|
345
357
|
readonly handover?: { readonly entityType: string; readonly id: string };
|
|
358
|
+
// Present only when the server's auth.postAuthLanding resolver returned a
|
|
359
|
+
// valid path — see auth-routes.ts.
|
|
360
|
+
readonly landingPath?: string;
|
|
346
361
|
};
|
|
347
362
|
|
|
348
363
|
export async function confirmSignup(
|
|
@@ -28,3 +28,13 @@ export function resolveLoggedInHref<Args>(
|
|
|
28
28
|
): string {
|
|
29
29
|
return typeof href === "function" ? href(args) : href;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/** The server's `landingPath` (auth.postAuthLanding) wins over the screen's
|
|
33
|
+
* loggedInHref prop — that prop is a deprecated per-app fallback. */
|
|
34
|
+
export function resolvePostAuthHref<Args>(
|
|
35
|
+
landingPath: string | undefined,
|
|
36
|
+
href: string | ((args: Args) => string),
|
|
37
|
+
args: Args,
|
|
38
|
+
): string {
|
|
39
|
+
return landingPath ?? resolveLoggedInHref(href, args);
|
|
40
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
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 {
|
|
22
|
+
import { resolvePostAuthHref } from "./auth-form-logic";
|
|
23
23
|
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
24
24
|
import { SessionContext, UNAUTHENTICATED } from "./session";
|
|
25
25
|
|
|
@@ -29,7 +29,9 @@ export type InviteAcceptScreenProps = {
|
|
|
29
29
|
/** Where to redirect on success. Default "/" — multi-tenant apps can pass
|
|
30
30
|
* `(data) => "/${data.tenantId}/"`. Function-form receives the roles this
|
|
31
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.
|
|
32
|
+
* role, which for an existing member is not their full role set there.
|
|
33
|
+
* @deprecated Configure auth.postAuthLanding on the server; its landingPath wins.
|
|
34
|
+
* Kept as fallback. */
|
|
33
35
|
readonly loggedInHref?:
|
|
34
36
|
| string
|
|
35
37
|
| ((args: { tenantId: string; roles: readonly string[] }) => string);
|
|
@@ -46,6 +48,9 @@ type InviteAcceptResponse = {
|
|
|
46
48
|
readonly tenantId: string;
|
|
47
49
|
readonly role?: string;
|
|
48
50
|
readonly user?: { readonly roles?: readonly string[] };
|
|
51
|
+
// Present only when the server's auth.postAuthLanding resolver returned a
|
|
52
|
+
// valid path — see auth-routes.ts.
|
|
53
|
+
readonly landingPath?: string;
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
function grantedRoles(data: InviteAcceptResponse): readonly string[] {
|
|
@@ -92,7 +97,7 @@ export function InviteAcceptScreen({
|
|
|
92
97
|
// @cast-boundary engine-payload — auth route JSON
|
|
93
98
|
const data = (await res.json()) as InviteAcceptResponse;
|
|
94
99
|
window.location.assign(
|
|
95
|
-
|
|
100
|
+
resolvePostAuthHref(data.landingPath, loggedInHref, {
|
|
96
101
|
tenantId: data.tenantId,
|
|
97
102
|
roles: grantedRoles(data),
|
|
98
103
|
}),
|
|
@@ -117,7 +122,7 @@ export function InviteAcceptScreen({
|
|
|
117
122
|
// @cast-boundary engine-payload — auth route JSON
|
|
118
123
|
const data = (await res.json()) as InviteAcceptResponse;
|
|
119
124
|
window.location.assign(
|
|
120
|
-
|
|
125
|
+
resolvePostAuthHref(data.landingPath, loggedInHref, {
|
|
121
126
|
tenantId: data.tenantId,
|
|
122
127
|
roles: grantedRoles(data),
|
|
123
128
|
}),
|
|
@@ -142,7 +147,7 @@ export function InviteAcceptScreen({
|
|
|
142
147
|
// @cast-boundary engine-payload — auth route JSON
|
|
143
148
|
const data = (await res.json()) as InviteAcceptResponse;
|
|
144
149
|
window.location.assign(
|
|
145
|
-
|
|
150
|
+
resolvePostAuthHref(data.landingPath, loggedInHref, {
|
|
146
151
|
tenantId: data.tenantId,
|
|
147
152
|
roles: grantedRoles(data),
|
|
148
153
|
}),
|
|
@@ -10,15 +10,17 @@
|
|
|
10
10
|
// server-side token pass `token` as a prop instead.
|
|
11
11
|
//
|
|
12
12
|
// On success: shows a confirmation (account active, signed in) with a
|
|
13
|
-
// button to
|
|
14
|
-
// server already logged the user in via cookies, but a silent redirect
|
|
15
|
-
// leaves no signal that activation worked.
|
|
16
|
-
//
|
|
13
|
+
// button to the continue target, instead of navigating away immediately —
|
|
14
|
+
// the server already logged the user in via cookies, but a silent redirect
|
|
15
|
+
// leaves no signal that activation worked. Server-side auth.postAuthLanding
|
|
16
|
+
// (landingPath) wins over loggedInHref; loggedInHref is the deprecated
|
|
17
|
+
// per-app fallback. Default pattern is "/" — apps with multi-tenant routing
|
|
18
|
+
// pass `(data) => "/" + data.tenantKey + "/"`.
|
|
17
19
|
|
|
18
20
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
21
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
20
22
|
import { confirmSignup, type SignupConfirmSuccess } from "./auth-client";
|
|
21
|
-
import { passwordPairIssue,
|
|
23
|
+
import { passwordPairIssue, resolvePostAuthHref } from "./auth-form-logic";
|
|
22
24
|
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
23
25
|
|
|
24
26
|
export type SignupCompleteScreenProps = {
|
|
@@ -27,7 +29,9 @@ export type SignupCompleteScreenProps = {
|
|
|
27
29
|
readonly token?: string;
|
|
28
30
|
/** Where to send the user after activation. Function-form receives the tenantKey, the
|
|
29
31
|
* roles granted by this flow in that tenant, and the claimed `handover` entity when
|
|
30
|
-
* signup claimed a try-first grant. Default "/".
|
|
32
|
+
* signup claimed a try-first grant. Default "/".
|
|
33
|
+
* @deprecated Configure auth.postAuthLanding on the server; its landingPath wins.
|
|
34
|
+
* Kept as fallback. */
|
|
31
35
|
readonly loggedInHref?:
|
|
32
36
|
| string
|
|
33
37
|
| ((args: {
|
|
@@ -73,7 +77,7 @@ export function SignupCompleteScreen({
|
|
|
73
77
|
// explicit continue button instead of navigating away silently —
|
|
74
78
|
// the user otherwise gets no signal that activation worked.
|
|
75
79
|
setContinueHref(
|
|
76
|
-
|
|
80
|
+
resolvePostAuthHref(res.data.landingPath, loggedInHref, {
|
|
77
81
|
tenantKey: res.data.tenantKey,
|
|
78
82
|
roles: res.data.user.roles,
|
|
79
83
|
...(res.data.handover !== undefined && { handover: res.data.handover }),
|
|
@@ -32,6 +32,7 @@ export async function verifyMfaChallenge(
|
|
|
32
32
|
isSuccess?: boolean;
|
|
33
33
|
token?: string;
|
|
34
34
|
user?: LoginResponse["user"];
|
|
35
|
+
landingPath?: string;
|
|
35
36
|
error?:
|
|
36
37
|
| {
|
|
37
38
|
code?: string;
|
|
@@ -41,7 +42,14 @@ export async function verifyMfaChallenge(
|
|
|
41
42
|
| string;
|
|
42
43
|
};
|
|
43
44
|
if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
|
|
44
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
kind: "success",
|
|
47
|
+
data: {
|
|
48
|
+
token: body.token,
|
|
49
|
+
user: body.user,
|
|
50
|
+
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
45
53
|
}
|
|
46
54
|
const err = body.error;
|
|
47
55
|
if (typeof err === "string") {
|
|
@@ -156,6 +164,7 @@ export async function confirmMfaSetupPreauth(
|
|
|
156
164
|
isSuccess?: boolean;
|
|
157
165
|
token?: string;
|
|
158
166
|
user?: LoginResponse["user"];
|
|
167
|
+
landingPath?: string;
|
|
159
168
|
error?:
|
|
160
169
|
| {
|
|
161
170
|
code?: string;
|
|
@@ -165,7 +174,14 @@ export async function confirmMfaSetupPreauth(
|
|
|
165
174
|
| string;
|
|
166
175
|
};
|
|
167
176
|
if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
|
|
168
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
kind: "success",
|
|
179
|
+
data: {
|
|
180
|
+
token: body.token,
|
|
181
|
+
user: body.user,
|
|
182
|
+
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
169
185
|
}
|
|
170
186
|
const err = body.error;
|
|
171
187
|
if (typeof err === "string") {
|
|
@@ -77,6 +77,7 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
|
|
|
77
77
|
// exercise — the hardened create-checkout-session now requires every
|
|
78
78
|
// mode:"subscription" priceId to resolve through priceToTier.
|
|
79
79
|
priceToTier: { price_pro_test: "pro", price_business_test: "business" },
|
|
80
|
+
oneOffPriceIds: ["price_topup_test"],
|
|
80
81
|
createCheckoutSession: async (_ctx, options) => {
|
|
81
82
|
mockCheckoutCalls.push({
|
|
82
83
|
priceId: options.priceId,
|
|
@@ -178,6 +179,7 @@ function buildEvent(
|
|
|
178
179
|
providerCustomerId: string;
|
|
179
180
|
providerSubscriptionId: string;
|
|
180
181
|
currentPeriodEndIso: string;
|
|
182
|
+
cancelAtIso: string | null;
|
|
181
183
|
rawPayload: string;
|
|
182
184
|
}> = {},
|
|
183
185
|
) {
|
|
@@ -190,6 +192,12 @@ function buildEvent(
|
|
|
190
192
|
status: overrides.status ?? SubscriptionStatuses.active,
|
|
191
193
|
tier: overrides.tier ?? "pro",
|
|
192
194
|
currentPeriodEndIso: overrides.currentPeriodEndIso ?? "2026-06-01T00:00:00Z",
|
|
195
|
+
// `cancelAtIso` stays entirely absent from the payload unless an override
|
|
196
|
+
// sets it — undefined (key omitted) vs. null (explicit clear) is the
|
|
197
|
+
// 3-way distinction cancelAtSetFromPayload relies on; a `?? undefined`
|
|
198
|
+
// default here would collapse "omitted" into "explicit null" and hide a
|
|
199
|
+
// regression where a later event without the field wipes a prior value.
|
|
200
|
+
...(overrides.cancelAtIso !== undefined && { cancelAtIso: overrides.cancelAtIso }),
|
|
193
201
|
rawPayload: overrides.rawPayload ?? '{"raw":"payload"}',
|
|
194
202
|
};
|
|
195
203
|
}
|
|
@@ -677,7 +685,7 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
|
|
|
677
685
|
|
|
678
686
|
const result = (await stack.http.writeOk(
|
|
679
687
|
"billing-foundation:write:create-portal-session",
|
|
680
|
-
{
|
|
688
|
+
{},
|
|
681
689
|
admin,
|
|
682
690
|
)) as Record<string, unknown>;
|
|
683
691
|
|
|
@@ -687,11 +695,13 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
|
|
|
687
695
|
// Drift-pin: portal-handler liest providerCustomerId AUS DER DB
|
|
688
696
|
// (subscription-row), nicht aus der payload. Wenn ein Refactor das
|
|
689
697
|
// umstellt (= Tenant könnte fremde portal-sessions öffnen), würde
|
|
690
|
-
// mockPortalCalls den falschen customer-id sehen.
|
|
698
|
+
// mockPortalCalls den falschen customer-id sehen. returnUrl is now
|
|
699
|
+
// server-computed (portalReturnUrl) — no catalog mounted here, so it
|
|
700
|
+
// collapses onto baseUrl itself.
|
|
691
701
|
expect(mockPortalCalls).toHaveLength(1);
|
|
692
702
|
expect(mockPortalCalls[0]).toEqual({
|
|
693
703
|
providerCustomerId: "cus_3013",
|
|
694
|
-
returnUrl: "https://example.com
|
|
704
|
+
returnUrl: "https://example.com",
|
|
695
705
|
});
|
|
696
706
|
});
|
|
697
707
|
|
|
@@ -699,7 +709,7 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
|
|
|
699
709
|
const admin = adminFor(3011);
|
|
700
710
|
const error = await stack.http.writeErr(
|
|
701
711
|
"billing-foundation:write:create-portal-session",
|
|
702
|
-
{
|
|
712
|
+
{},
|
|
703
713
|
admin,
|
|
704
714
|
);
|
|
705
715
|
expect(JSON.stringify(error)).toMatch(/no active subscription/);
|
|
@@ -746,6 +756,68 @@ describe("scenario 8: cancel-event setzt status auf canceled, behält subscripti
|
|
|
746
756
|
});
|
|
747
757
|
});
|
|
748
758
|
|
|
759
|
+
describe("scenario 8b: cancelAt — set / leave-unchanged / clear across events", () => {
|
|
760
|
+
test("event without cancelAtIso key keeps a prior value; explicit null clears it", async () => {
|
|
761
|
+
const admin = adminFor(3009);
|
|
762
|
+
|
|
763
|
+
// 1. create with cancelAt set — a Stripe cancel_at_period_end webhook.
|
|
764
|
+
await stack.http.writeOk(
|
|
765
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
766
|
+
buildEvent({
|
|
767
|
+
providerEventId: "evt_3009_create",
|
|
768
|
+
providerCustomerId: "cus_3009",
|
|
769
|
+
providerSubscriptionId: "sub_3009",
|
|
770
|
+
cancelAtIso: "2026-07-01T00:00:00Z",
|
|
771
|
+
}),
|
|
772
|
+
admin,
|
|
773
|
+
);
|
|
774
|
+
let subs = (await stack.http.queryOk(
|
|
775
|
+
"billing-foundation:query:subscription:list",
|
|
776
|
+
{},
|
|
777
|
+
admin,
|
|
778
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
779
|
+
expect(subs.rows[0]?.["cancelAt"]).toBe("2026-07-01T00:00:00Z");
|
|
780
|
+
|
|
781
|
+
// 2. an unrelated update event that never mentions cancelAtIso (e.g. a
|
|
782
|
+
// price/tier change) must NOT wipe the previously recorded cancelAt.
|
|
783
|
+
await stack.http.writeOk(
|
|
784
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
785
|
+
buildEvent({
|
|
786
|
+
providerEventId: "evt_3009_update",
|
|
787
|
+
type: SubscriptionEventTypes.updated,
|
|
788
|
+
providerCustomerId: "cus_3009",
|
|
789
|
+
providerSubscriptionId: "sub_3009",
|
|
790
|
+
tier: "business",
|
|
791
|
+
}),
|
|
792
|
+
admin,
|
|
793
|
+
);
|
|
794
|
+
subs = (await stack.http.queryOk("billing-foundation:query:subscription:list", {}, admin)) as {
|
|
795
|
+
rows: Array<Record<string, unknown>>;
|
|
796
|
+
};
|
|
797
|
+
expect(subs.rows[0]?.["tier"]).toBe("business");
|
|
798
|
+
expect(subs.rows[0]?.["cancelAt"]).toBe("2026-07-01T00:00:00Z");
|
|
799
|
+
|
|
800
|
+
// 3. reactivation — an update event with cancelAtIso explicitly null
|
|
801
|
+
// clears it.
|
|
802
|
+
await stack.http.writeOk(
|
|
803
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
804
|
+
buildEvent({
|
|
805
|
+
providerEventId: "evt_3009_reactivate",
|
|
806
|
+
type: SubscriptionEventTypes.updated,
|
|
807
|
+
providerCustomerId: "cus_3009",
|
|
808
|
+
providerSubscriptionId: "sub_3009",
|
|
809
|
+
tier: "business",
|
|
810
|
+
cancelAtIso: null,
|
|
811
|
+
}),
|
|
812
|
+
admin,
|
|
813
|
+
);
|
|
814
|
+
subs = (await stack.http.queryOk("billing-foundation:query:subscription:list", {}, admin)) as {
|
|
815
|
+
rows: Array<Record<string, unknown>>;
|
|
816
|
+
};
|
|
817
|
+
expect(subs.rows[0]?.["cancelAt"]).toBeNull();
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
749
821
|
// scenario 9 (subscriptionTenantDestroyHook direct-call, default+HGB branches) removed:
|
|
750
822
|
// covered end-to-end by billing-foundation/__tests__/tenant-destroy.integration.test.ts,
|
|
751
823
|
// which drives the real tenant-lifecycle sweep instead of hand-feeding the hook.
|