@cosmicdrift/kumiko-bundled-features 0.343.0 → 0.345.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/dist/audit/run-escape-hatch-retention.js +25 -12
- package/dist/auth-email-password/constants.d.ts +1 -0
- package/dist/auth-email-password/constants.js +1 -0
- package/dist/auth-email-password/feature.js +6 -2
- package/dist/auth-email-password/handlers/invite-signup-complete.write.d.ts +6 -2
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +14 -4
- package/dist/auth-email-password/handlers/signup-confirm.write.d.ts +6 -2
- package/dist/auth-email-password/handlers/signup-confirm.write.js +8 -1
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +9 -0
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +39 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +7 -1
- package/dist/auth-email-password/i18n.js +1 -0
- package/dist/auth-email-password/web/auth-client.d.ts +6 -1
- package/dist/auth-email-password/web/auth-client.js +6 -2
- package/dist/auth-email-password/web/signup-complete-screen.js +9 -1
- package/dist/billing-foundation/checkout-core.d.ts +15 -4
- package/dist/billing-foundation/checkout-core.js +18 -6
- package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +13 -3
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +23 -20
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +6 -1
- package/dist/billing-foundation/consumer-protection/consent-gate.js +6 -1
- package/dist/billing-foundation/consumer-protection/consent-locale.js +1 -1
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +24 -2
- package/dist/billing-foundation/events.d.ts +2 -0
- package/dist/billing-foundation/events.js +2 -0
- package/dist/billing-foundation/handlers/billing-plans.query.d.ts +1 -1
- package/dist/billing-foundation/handlers/billing-plans.query.js +5 -1
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +46 -24
- package/dist/billing-foundation/index.d.ts +3 -1
- package/dist/billing-foundation/index.js +2 -0
- package/dist/billing-foundation/types.d.ts +16 -7
- package/dist/billing-foundation/types.js +3 -0
- package/dist/billing-foundation/validate-options.d.ts +1 -1
- package/dist/billing-foundation/validate-options.js +24 -2
- package/dist/billing-foundation/web/billing-plans-panel.js +1 -1
- package/dist/billing-foundation/web/cancel-contract-dialog.js +2 -2
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +1 -1
- package/dist/billing-foundation/web/checkout-consent-dialog.js +4 -4
- package/dist/billing-foundation/web/index.d.ts +3 -1
- package/dist/billing-foundation/web/index.js +2 -0
- package/dist/legal-pages/constants.d.ts +2 -0
- package/dist/legal-pages/feature.js +7 -2
- package/dist/renderer-simple/simple-renderer.js +8 -0
- package/dist/step-dispatcher/feature.js +2 -1
- package/dist/step-dispatcher/webhook-runner.d.ts +2 -0
- package/dist/step-dispatcher/webhook-runner.js +7 -0
- package/package.json +9 -9
- package/src/audit/changes.json +6 -0
- package/src/auth-email-password/changes.json +24 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/legal-pages/changes.json +6 -0
- package/src/renderer-simple/changes.json +6 -0
- package/src/step-dispatcher/changes.json +6 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { aggregateWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { COMPLIANCE_PROFILES, subtractRetentionSpec, } from "@cosmicdrift/kumiko-framework/compliance";
|
|
3
3
|
import { parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -38,10 +38,14 @@ function targetTenantOf(payload) {
|
|
|
38
38
|
const parsed = escapeHatchUsedSchema.safeParse(payload);
|
|
39
39
|
return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
|
|
40
40
|
}
|
|
41
|
-
async function
|
|
42
|
-
const
|
|
43
|
-
|
|
41
|
+
async function storingTenantIds(db) {
|
|
42
|
+
const groups = await aggregateWhere(db, eventsTable, { measure: { fn: "count" }, groupBy: [{ field: "tenantId" }] }, { aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE });
|
|
43
|
+
return groups.flatMap((group) => {
|
|
44
|
+
const tenantId = parseTenantId(group.keys[0]);
|
|
45
|
+
return tenantId ? [tenantId] : [];
|
|
44
46
|
});
|
|
47
|
+
}
|
|
48
|
+
async function pruneByTenantProfiles(db) {
|
|
45
49
|
const now = getTemporal().Now.instant();
|
|
46
50
|
const cutoffByTenant = new Map();
|
|
47
51
|
const cutoffOf = async (tenantId) => {
|
|
@@ -52,15 +56,24 @@ async function pruneByTenantProfiles(db) {
|
|
|
52
56
|
cutoffByTenant.set(tenantId, cutoff);
|
|
53
57
|
return cutoff;
|
|
54
58
|
};
|
|
59
|
+
// The effective cutoff is never later than the storing tenant's, so only rows older than
|
|
60
|
+
// that one can expire; everything younger stays in the database.
|
|
55
61
|
const expiredAggregateIds = [];
|
|
56
|
-
for (const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: storeCutoff
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
for (const storingTenantId of await storingTenantIds(db)) {
|
|
63
|
+
const storeCutoff = await cutoffOf(storingTenantId);
|
|
64
|
+
const candidates = await selectMany(db, eventsTable, {
|
|
65
|
+
aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
|
|
66
|
+
tenantId: storingTenantId,
|
|
67
|
+
createdAt: { lt: storeCutoff },
|
|
68
|
+
});
|
|
69
|
+
for (const row of candidates) {
|
|
70
|
+
const targetTenantId = targetTenantOf(row.payload);
|
|
71
|
+
const cutoff = targetTenantId
|
|
72
|
+
? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
|
|
73
|
+
: storeCutoff;
|
|
74
|
+
if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
|
|
75
|
+
expiredAggregateIds.push(row.aggregateId);
|
|
76
|
+
}
|
|
64
77
|
}
|
|
65
78
|
}
|
|
66
79
|
let deletedCount = 0;
|
|
@@ -17,6 +17,7 @@ export declare const AuthHandlers: {
|
|
|
17
17
|
readonly inviteAccept: "auth-email-password:write:invite-accept";
|
|
18
18
|
readonly inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login";
|
|
19
19
|
readonly inviteSignupComplete: "auth-email-password:write:invite-signup-complete";
|
|
20
|
+
readonly switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate";
|
|
20
21
|
readonly inviteCancel: "auth-email-password:write:invite-cancel";
|
|
21
22
|
};
|
|
22
23
|
export declare const AuthQueries: {
|
|
@@ -44,6 +44,7 @@ export const AuthHandlers = {
|
|
|
44
44
|
inviteAccept: "auth-email-password:write:invite-accept",
|
|
45
45
|
inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
|
|
46
46
|
inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
|
|
47
|
+
switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate",
|
|
47
48
|
inviteCancel: "auth-email-password:write:invite-cancel",
|
|
48
49
|
};
|
|
49
50
|
// Qualified query names. Anonymous-readable status so the (unauthenticated)
|
|
@@ -17,6 +17,7 @@ import { createResetPasswordHandler } from "./handlers/reset-password.write.js";
|
|
|
17
17
|
import { selfRegistrationStatusQuery } from "./handlers/self-registration-status.query.js";
|
|
18
18
|
import { createSignupConfirmHandler } from "./handlers/signup-confirm.write.js";
|
|
19
19
|
import { createSignupRequestHandler, } from "./handlers/signup-request.write.js";
|
|
20
|
+
import { createSwitchTenantMfaGateHandler } from "./handlers/switch-tenant-mfa-gate.write.js";
|
|
20
21
|
import { createVerifyEmailHandler } from "./handlers/verify-email.write.js";
|
|
21
22
|
/**
|
|
22
23
|
* Env-vars contract for the `auth-email-password` feature.
|
|
@@ -101,6 +102,9 @@ export function createAuthEmailPasswordFeature(opts = {}) {
|
|
|
101
102
|
changePassword: r.writeHandler(changePasswordWrite),
|
|
102
103
|
logout: r.writeHandler(logoutWrite),
|
|
103
104
|
};
|
|
105
|
+
if (opts.mfaStatusChecker) {
|
|
106
|
+
r.writeHandler(createSwitchTenantMfaGateHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
|
|
107
|
+
}
|
|
104
108
|
if (opts.passwordReset) {
|
|
105
109
|
r.writeHandler(createRequestPasswordResetHandler(opts.passwordReset));
|
|
106
110
|
r.writeHandler(createResetPasswordHandler(opts.passwordReset));
|
|
@@ -119,7 +123,7 @@ export function createAuthEmailPasswordFeature(opts = {}) {
|
|
|
119
123
|
};
|
|
120
124
|
if (opts.signup) {
|
|
121
125
|
r.writeHandler(createSignupRequestHandler(opts.signup));
|
|
122
|
-
r.writeHandler(createSignupConfirmHandler());
|
|
126
|
+
r.writeHandler(createSignupConfirmHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
|
|
123
127
|
}
|
|
124
128
|
if (opts.invite) {
|
|
125
129
|
r.writeHandler(createInviteCreateHandler(opts.invite));
|
|
@@ -132,7 +136,7 @@ export function createAuthEmailPasswordFeature(opts = {}) {
|
|
|
132
136
|
accountLockout: opts.accountLockout,
|
|
133
137
|
strictEmailVerification: strictVerification,
|
|
134
138
|
}));
|
|
135
|
-
r.writeHandler(createInviteSignupCompleteHandler());
|
|
139
|
+
r.writeHandler(createInviteSignupCompleteHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
|
|
136
140
|
}
|
|
137
141
|
if (opts.accountUnlock) {
|
|
138
142
|
r.writeHandler(createRequestAccountUnlockHandler(opts.accountUnlock));
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import { type LoginHandlerOptions, type LoginResult } from "./login.write.js";
|
|
3
4
|
export type InviteSignupCompleteData = {
|
|
4
5
|
readonly kind: "auth-session";
|
|
5
6
|
readonly session: SessionUser;
|
|
6
7
|
readonly tenantId: TenantId;
|
|
7
8
|
readonly role: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
9
|
+
} | Exclude<LoginResult, {
|
|
10
|
+
readonly kind: "auth-session";
|
|
11
|
+
}>;
|
|
12
|
+
export type InviteSignupCompleteOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
|
|
13
|
+
export declare function createInviteSignupCompleteHandler(opts?: InviteSignupCompleteOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"invite-signup-complete", z.ZodObject<{
|
|
10
14
|
token: z.ZodString;
|
|
11
15
|
password: z.ZodString;
|
|
12
16
|
}, z.core.$strip>, InviteSignupCompleteData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -37,13 +37,14 @@ import { burnInviteToken, deleteInviteToken, getInvitationIdForToken, unburnInvi
|
|
|
37
37
|
import { passwordSchema } from "../password-policy.js";
|
|
38
38
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
39
39
|
import { seedUserWithPassword } from "../seeding.js";
|
|
40
|
+
import { gateEnforceMfa } from "./login.write.js";
|
|
40
41
|
const InviteSignupCompleteSchema = z.object({
|
|
41
42
|
token: z.string().min(1),
|
|
42
43
|
password: passwordSchema,
|
|
43
44
|
});
|
|
44
45
|
const invitationExecutor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, { entityName: "tenant-invitation" });
|
|
45
46
|
const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON = "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists.";
|
|
46
|
-
export function createInviteSignupCompleteHandler() {
|
|
47
|
+
export function createInviteSignupCompleteHandler(opts = {}) {
|
|
47
48
|
return defineWriteHandler({
|
|
48
49
|
name: "invite-signup-complete",
|
|
49
50
|
schema: InviteSignupCompleteSchema,
|
|
@@ -119,13 +120,22 @@ export function createInviteSignupCompleteHandler() {
|
|
|
119
120
|
}, createSystemUser(invitationTenantId), invitationTdb);
|
|
120
121
|
if (!updateResult.isSuccess)
|
|
121
122
|
return updateResult;
|
|
123
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
124
|
+
// a reserved role on the invitation itself must never reach the session.
|
|
125
|
+
const mergedRoles = buildSessionRoles(invitationGlobalRoles, [invitationRole]);
|
|
126
|
+
// Same gate as invite-accept-with-login: the account exists after this
|
|
127
|
+
// call, but an MFA-gated role gets the MFA step instead of a session.
|
|
128
|
+
// It runs before the token is deleted so a throwing check leaves the link retryable.
|
|
129
|
+
const mfaGate = await gateEnforceMfa(ctx, { mfaStatusChecker: opts.mfaStatusChecker }, userId, invitationTenantId, mergedRoles);
|
|
122
130
|
await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
|
|
131
|
+
if (mfaGate !== undefined) {
|
|
132
|
+
committed = true;
|
|
133
|
+
return { isSuccess: true, data: mfaGate };
|
|
134
|
+
}
|
|
123
135
|
const session = {
|
|
124
136
|
id: userId,
|
|
125
137
|
tenantId: invitationTenantId,
|
|
126
|
-
|
|
127
|
-
// a reserved role on the invitation itself must never reach the session.
|
|
128
|
-
roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
|
|
138
|
+
roles: mergedRoles,
|
|
129
139
|
};
|
|
130
140
|
committed = true;
|
|
131
141
|
return {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import { type LoginHandlerOptions, type LoginResult } from "./login.write.js";
|
|
3
4
|
export type SignupConfirmData = {
|
|
4
5
|
readonly kind: "auth-session";
|
|
5
6
|
readonly session: SessionUser;
|
|
@@ -8,8 +9,11 @@ export type SignupConfirmData = {
|
|
|
8
9
|
readonly entityType: string;
|
|
9
10
|
readonly id: string;
|
|
10
11
|
};
|
|
11
|
-
}
|
|
12
|
-
|
|
12
|
+
} | Exclude<LoginResult, {
|
|
13
|
+
readonly kind: "auth-session";
|
|
14
|
+
}>;
|
|
15
|
+
export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
|
|
16
|
+
export declare function createSignupConfirmHandler(opts?: SignupConfirmOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"signup-confirm", z.ZodObject<{
|
|
13
17
|
token: z.ZodString;
|
|
14
18
|
password: z.ZodString;
|
|
15
19
|
}, z.core.$strip>, SignupConfirmData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -32,6 +32,7 @@ import { passwordSchema } from "../password-policy.js";
|
|
|
32
32
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
33
33
|
import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding.js";
|
|
34
34
|
import { burnSignupToken, deleteSignupHandover, deleteSignupToken, getEmailForSignupToken, getSignupHandover, unburnSignupToken, } from "../signup-token-store.js";
|
|
35
|
+
import { gateEnforceMfa } from "./login.write.js";
|
|
35
36
|
const SignupConfirmSchema = z.object({
|
|
36
37
|
token: z.string().min(8),
|
|
37
38
|
password: passwordSchema,
|
|
@@ -70,7 +71,7 @@ async function claimBoundHandover(ctx, session, binding) {
|
|
|
70
71
|
});
|
|
71
72
|
return undefined;
|
|
72
73
|
}
|
|
73
|
-
export function createSignupConfirmHandler() {
|
|
74
|
+
export function createSignupConfirmHandler(opts = {}) {
|
|
74
75
|
return defineWriteHandler({
|
|
75
76
|
name: "signup-confirm",
|
|
76
77
|
schema: SignupConfirmSchema,
|
|
@@ -158,11 +159,17 @@ export function createSignupConfirmHandler() {
|
|
|
158
159
|
const handover = handoverBinding
|
|
159
160
|
? await claimBoundHandover(ctx, session, handoverBinding)
|
|
160
161
|
: undefined;
|
|
162
|
+
// Before the token keys go: a throwing MFA check must leave the link retryable.
|
|
163
|
+
const mfaGate = await gateEnforceMfa(ctx, { mfaStatusChecker: opts.mfaStatusChecker }, provisioned.userId, provisioned.tenantId, session.roles);
|
|
161
164
|
// Drop both token lookup keys; the burn key stays for its remaining
|
|
162
165
|
// TTL as replay protection.
|
|
163
166
|
await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
|
|
164
167
|
if (handoverBinding)
|
|
165
168
|
await deleteSignupHandover(ctx.redis, event.payload.token);
|
|
169
|
+
if (mfaGate !== undefined) {
|
|
170
|
+
committed = true;
|
|
171
|
+
return { isSuccess: true, data: mfaGate };
|
|
172
|
+
}
|
|
166
173
|
committed = true;
|
|
167
174
|
return {
|
|
168
175
|
isSuccess: true,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { gateEnforceMfa, type LoginHandlerOptions } from "./login.write.js";
|
|
3
|
+
export type SwitchTenantMfaGateData = {
|
|
4
|
+
readonly kind: "mfa-gate-clear";
|
|
5
|
+
} | NonNullable<Awaited<ReturnType<typeof gateEnforceMfa>>>;
|
|
6
|
+
export declare function createSwitchTenantMfaGateHandler(opts: Pick<LoginHandlerOptions, "mfaStatusChecker">): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"switch-tenant-mfa-gate", z.ZodObject<{
|
|
7
|
+
userId: z.ZodString;
|
|
8
|
+
tenantId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
9
|
+
}, z.core.$strip>, SwitchTenantMfaGateData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { buildSessionRoles, createSystemUser, defineWriteHandler, SYSTEM_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { UserQueries } from "../../user/index.js";
|
|
5
|
+
import { parseAuthUserRow } from "../auth-user-row.js";
|
|
6
|
+
import { noMembership } from "../errors.js";
|
|
7
|
+
import { gateEnforceMfa } from "./login.write.js";
|
|
8
|
+
const SwitchTenantMfaGateSchema = z.object({
|
|
9
|
+
userId: z.string().min(1),
|
|
10
|
+
tenantId: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.transform((id) => id),
|
|
14
|
+
});
|
|
15
|
+
// Dispatched by POST /auth/switch-tenant with the system identity. The handler
|
|
16
|
+
// resolves membership and roles itself, so the gate never trusts caller-supplied
|
|
17
|
+
// roles; system-only access keeps it out of reach of /api/write callers.
|
|
18
|
+
export function createSwitchTenantMfaGateHandler(opts) {
|
|
19
|
+
return defineWriteHandler({
|
|
20
|
+
name: "switch-tenant-mfa-gate",
|
|
21
|
+
schema: SwitchTenantMfaGateSchema,
|
|
22
|
+
access: { roles: [SYSTEM_ROLE] },
|
|
23
|
+
escapeHatch: {
|
|
24
|
+
reason: "reads the MFA enrollment of the switch target tenant, not the caller's current tenant, via the mfaStatusChecker callback (it uses ctx.db.unsafeRaw())",
|
|
25
|
+
},
|
|
26
|
+
agent: { expose: false },
|
|
27
|
+
description: "Runs the MFA gate of the login for a tenant switch and answers with an MFA challenge, a setup requirement, or clearance.",
|
|
28
|
+
handler: async (event, ctx) => {
|
|
29
|
+
const { userId, tenantId } = event.payload;
|
|
30
|
+
const active = await ctx.resolveActiveMembership(userId, tenantId);
|
|
31
|
+
if (active.kind === "rejected")
|
|
32
|
+
return noMembership();
|
|
33
|
+
const userRow = parseAuthUserRow(await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, { id: userId }));
|
|
34
|
+
const roles = buildSessionRoles(parseRoles(userRow?.roles ?? null), active.membership.roles);
|
|
35
|
+
const mfaGate = await gateEnforceMfa(ctx, opts, userId, tenantId, roles);
|
|
36
|
+
return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear" } };
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// spec rather than duplicated across two near-identical handler bodies.
|
|
14
14
|
import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
16
|
+
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
16
17
|
import * as z from "zod";
|
|
17
18
|
import { UserQueries } from "../../user/index.js";
|
|
18
19
|
import { parseAuthUserRow } from "../auth-user-row.js";
|
|
@@ -20,6 +21,11 @@ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-m
|
|
|
20
21
|
const RequestTokenSchema = z.object({
|
|
21
22
|
email: z.email(),
|
|
22
23
|
});
|
|
24
|
+
// The requester is anonymous, so ctx.tz.user is only the tenant default; the recipient's
|
|
25
|
+
// own profile zone is the one the expiry should be read in.
|
|
26
|
+
function recipientTimeZone(user, fallbackTimeZone) {
|
|
27
|
+
return user.timezone && isValidIanaTimeZone(user.timezone) ? user.timezone : fallbackTimeZone;
|
|
28
|
+
}
|
|
23
29
|
export function createTokenRequestHandler(spec, opts) {
|
|
24
30
|
const ttl = opts.tokenTtlMinutes ?? spec.defaultTtlMinutes;
|
|
25
31
|
return defineWriteHandler({
|
|
@@ -69,7 +75,7 @@ export function createTokenRequestHandler(spec, opts) {
|
|
|
69
75
|
token,
|
|
70
76
|
expiresAt: expiresAt.toString(),
|
|
71
77
|
issuedAt,
|
|
72
|
-
timeZone: ctx.tz.user,
|
|
78
|
+
timeZone: recipientTimeZone(user, ctx.tz.user),
|
|
73
79
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
74
80
|
locale,
|
|
75
81
|
});
|
|
@@ -99,6 +99,7 @@ export const defaultTranslations = {
|
|
|
99
99
|
"auth.signupComplete.submit": "Activate account",
|
|
100
100
|
"auth.signupComplete.submitting": "…",
|
|
101
101
|
"auth.signupComplete.missingToken": "Activation link is missing a token. Please request a new one.",
|
|
102
|
+
"auth.signupComplete.activatedMfaPending": "Your account is active. Sign in to set up two-factor authentication.",
|
|
102
103
|
"auth.signupComplete.activatedTitle": "Account activated",
|
|
103
104
|
"auth.signupComplete.activated": "Your account is active and you're signed in.",
|
|
104
105
|
"auth.signupComplete.continue": "Continue",
|
|
@@ -112,9 +112,14 @@ export type SignupConfirmSuccess = {
|
|
|
112
112
|
};
|
|
113
113
|
readonly landingPath?: string;
|
|
114
114
|
};
|
|
115
|
+
export type SignupConfirmResult = ({
|
|
116
|
+
readonly kind: "signed-in";
|
|
117
|
+
} & SignupConfirmSuccess) | {
|
|
118
|
+
readonly kind: "mfa-pending";
|
|
119
|
+
};
|
|
115
120
|
export declare function confirmSignup(token: string, password: string): Promise<{
|
|
116
121
|
ok: true;
|
|
117
|
-
data:
|
|
122
|
+
data: SignupConfirmResult;
|
|
118
123
|
} | {
|
|
119
124
|
ok: false;
|
|
120
125
|
error: AuthTokenFailure;
|
|
@@ -229,8 +229,12 @@ export async function confirmSignup(token, password) {
|
|
|
229
229
|
body: JSON.stringify({ token, password }),
|
|
230
230
|
});
|
|
231
231
|
if (res.ok) {
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
// @cast-boundary engine-payload
|
|
233
|
+
const body = (await res.json());
|
|
234
|
+
if (body.mfaRequired === true || body.mfaSetupRequired === true) {
|
|
235
|
+
return { ok: true, data: { kind: "mfa-pending" } };
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, data: { kind: "signed-in", ...body } };
|
|
234
238
|
}
|
|
235
239
|
return { ok: false, error: await parseTokenFailure(res) };
|
|
236
240
|
}
|
|
@@ -31,6 +31,7 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
|
|
|
31
31
|
const [submitting, setSubmitting] = useState(false);
|
|
32
32
|
const [error, setError] = useState(null);
|
|
33
33
|
const [continueHref, setContinueHref] = useState(null);
|
|
34
|
+
const [mfaPending, setMfaPending] = useState(false);
|
|
34
35
|
const doSubmit = async () => {
|
|
35
36
|
setError(null);
|
|
36
37
|
const issue = passwordPairIssue(password, confirmPassword);
|
|
@@ -46,6 +47,11 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
|
|
|
46
47
|
const res = await confirmSignup(token, password);
|
|
47
48
|
setSubmitting(false);
|
|
48
49
|
if (res.ok) {
|
|
50
|
+
if (res.data.kind === "mfa-pending") {
|
|
51
|
+
setMfaPending(true);
|
|
52
|
+
setContinueHref(loginHref);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
49
55
|
// Cookies are already set (auto-login). Show a confirmation with an
|
|
50
56
|
// explicit continue button instead of navigating away silently —
|
|
51
57
|
// the user otherwise gets no signal that activation worked.
|
|
@@ -80,7 +86,9 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
|
|
|
80
86
|
return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] }));
|
|
81
87
|
}
|
|
82
88
|
if (continueHref !== null) {
|
|
83
|
-
return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t(
|
|
89
|
+
return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t(mfaPending
|
|
90
|
+
? "auth.signupComplete.activatedMfaPending"
|
|
91
|
+
: "auth.signupComplete.activated") }), _jsx(Link, { href: continueHref, variant: "button", children: t("auth.signupComplete.continue") })] }));
|
|
84
92
|
}
|
|
85
93
|
return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-password", label: t("auth.signupComplete.password"), required: true, children: _jsx(Input, { kind: "password", id: "signup-password", name: "signup-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "signup-confirm-password", label: t("auth.signupComplete.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "signup-confirm-password", name: "signup-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signupComplete.submitting") : t("auth.signupComplete.submit") })] })] }));
|
|
86
94
|
}
|
|
@@ -92,11 +92,22 @@ export type OpenCheckoutInput = {
|
|
|
92
92
|
readonly locale?: string;
|
|
93
93
|
readonly submitMessage?: string;
|
|
94
94
|
};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
type CheckoutCapableProvider = SubscriptionProviderPlugin & {
|
|
96
|
+
readonly createCheckoutSession: NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>;
|
|
97
|
+
};
|
|
98
|
+
/** All gates that run before a provider checkout call: provider, billing
|
|
99
|
+
* enabled, redirect origins, mode gates, foreign provider customer.
|
|
100
|
+
* `mode: "payment"` (one-off top-ups etc.) skips the price/subscription
|
|
101
|
+
* gates — those apply to recurring subscriptions only. */
|
|
102
|
+
export declare function assertCheckoutAllowed(ctx: HandlerContext, options: OpenCheckoutOptions, input: OpenCheckoutInput): Promise<CheckoutCapableProvider>;
|
|
103
|
+
export declare function startProviderCheckout(ctx: HandlerContext, plugin: CheckoutCapableProvider, input: OpenCheckoutInput): Promise<{
|
|
104
|
+
readonly url: string;
|
|
105
|
+
readonly providerName: string;
|
|
106
|
+
}>;
|
|
107
|
+
/** The one checkout entry-point start-plan-checkout, switch-plan and the
|
|
108
|
+
* non-consumer-protection create-checkout-session funnel through. */
|
|
99
109
|
export declare function openCheckout(ctx: HandlerContext, options: OpenCheckoutOptions, input: OpenCheckoutInput): Promise<{
|
|
100
110
|
readonly url: string;
|
|
101
111
|
readonly providerName: string;
|
|
102
112
|
}>;
|
|
113
|
+
export {};
|
|
@@ -234,13 +234,16 @@ function assertOneOffPriceAllowed(plugin, input) {
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
function canCreateCheckoutSession(plugin) {
|
|
238
|
+
return plugin.createCheckoutSession !== undefined;
|
|
239
|
+
}
|
|
240
|
+
/** All gates that run before a provider checkout call: provider, billing
|
|
241
|
+
* enabled, redirect origins, mode gates, foreign provider customer.
|
|
242
|
+
* `mode: "payment"` (one-off top-ups etc.) skips the price/subscription
|
|
243
|
+
* gates — those apply to recurring subscriptions only. */
|
|
244
|
+
export async function assertCheckoutAllowed(ctx, options, input) {
|
|
242
245
|
const { plugin } = resolveProviderPlugin(ctx, input.providerName);
|
|
243
|
-
if (!plugin
|
|
246
|
+
if (!canCreateCheckoutSession(plugin)) {
|
|
244
247
|
throw new Error(`subscription-foundation: provider "${input.providerName}" has no createCheckoutSession-method (e.g. Apple-IAP-only providers). Use the provider's native checkout flow.`);
|
|
245
248
|
}
|
|
246
249
|
// Checked before the redirect-origin hardening for every mode — a
|
|
@@ -272,6 +275,9 @@ export async function openCheckout(ctx, options, input) {
|
|
|
272
275
|
});
|
|
273
276
|
}
|
|
274
277
|
}
|
|
278
|
+
return plugin;
|
|
279
|
+
}
|
|
280
|
+
export async function startProviderCheckout(ctx, plugin, input) {
|
|
275
281
|
const result = await plugin.createCheckoutSession(ctx, {
|
|
276
282
|
priceId: input.priceId,
|
|
277
283
|
tenantId: ctx.user.tenantId,
|
|
@@ -288,3 +294,9 @@ export async function openCheckout(ctx, options, input) {
|
|
|
288
294
|
});
|
|
289
295
|
return { url: result.url, providerName: input.providerName };
|
|
290
296
|
}
|
|
297
|
+
/** The one checkout entry-point start-plan-checkout, switch-plan and the
|
|
298
|
+
* non-consumer-protection create-checkout-session funnel through. */
|
|
299
|
+
export async function openCheckout(ctx, options, input) {
|
|
300
|
+
const plugin = await assertCheckoutAllowed(ctx, options, input);
|
|
301
|
+
return startProviderCheckout(ctx, plugin, input);
|
|
302
|
+
}
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { CheckoutConsentRecordedPayload } from "../events.js";
|
|
2
|
+
export type ContractConfirmationSection = {
|
|
3
|
+
readonly text: string;
|
|
4
|
+
} | {
|
|
5
|
+
readonly heading: string;
|
|
6
|
+
} | {
|
|
7
|
+
readonly markdown: string;
|
|
8
|
+
};
|
|
2
9
|
export type ContractConfirmationContent = {
|
|
3
10
|
readonly subject: string;
|
|
4
11
|
readonly header: string;
|
|
5
|
-
readonly sections: readonly
|
|
6
|
-
readonly text: string;
|
|
7
|
-
}[];
|
|
12
|
+
readonly sections: readonly ContractConfirmationSection[];
|
|
8
13
|
readonly footer: string;
|
|
9
14
|
};
|
|
10
15
|
export type RenderContractConfirmationArgs = {
|
|
11
16
|
readonly consent: CheckoutConsentRecordedPayload;
|
|
17
|
+
readonly timeZone: string;
|
|
18
|
+
/** Translated plan name; falls back to the raw tier. */
|
|
19
|
+
readonly tierLabel?: string;
|
|
20
|
+
/** Translated purchased item for one-off payments; falls back to the generic label. */
|
|
21
|
+
readonly itemLabel?: string;
|
|
12
22
|
readonly consentGivenAtIso: string;
|
|
13
23
|
readonly contractStartIso: string;
|
|
14
24
|
readonly currentPeriodEndIso?: string;
|
|
@@ -3,8 +3,9 @@ import { CONSENT_TEXTS, resolveConsentLocale } from "./consent-text.js";
|
|
|
3
3
|
const LABELS = {
|
|
4
4
|
de: {
|
|
5
5
|
subject: "Vertragsbestätigung",
|
|
6
|
-
intro: "
|
|
6
|
+
intro: "Vielen Dank für deine Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
|
|
7
7
|
plan: "Tarif",
|
|
8
|
+
item: "Leistung",
|
|
8
9
|
oneOffPayment: "Einmalzahlung",
|
|
9
10
|
price: "Preis",
|
|
10
11
|
oneOff: "einmalig",
|
|
@@ -24,8 +25,9 @@ const LABELS = {
|
|
|
24
25
|
},
|
|
25
26
|
en: {
|
|
26
27
|
subject: "Contract confirmation",
|
|
27
|
-
intro: "
|
|
28
|
+
intro: "Thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
|
|
28
29
|
plan: "Plan",
|
|
30
|
+
item: "Item",
|
|
29
31
|
oneOffPayment: "One-off payment",
|
|
30
32
|
price: "Price",
|
|
31
33
|
oneOff: "one-off",
|
|
@@ -69,7 +71,7 @@ function formatPrice(consent, locale) {
|
|
|
69
71
|
: `${labels.every} ${count} ${unit.many}`;
|
|
70
72
|
return count === 1 ? `${amount} / ${intervalText}` : `${amount}, ${intervalText}`;
|
|
71
73
|
}
|
|
72
|
-
function formatDate(iso, locale) {
|
|
74
|
+
function formatDate(iso, locale, timeZone) {
|
|
73
75
|
let epochMilliseconds;
|
|
74
76
|
try {
|
|
75
77
|
epochMilliseconds = Temporal.Instant.from(iso).epochMilliseconds;
|
|
@@ -78,18 +80,15 @@ function formatDate(iso, locale) {
|
|
|
78
80
|
return iso;
|
|
79
81
|
}
|
|
80
82
|
return new Intl.DateTimeFormat(locale, {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
year: "numeric",
|
|
84
|
+
month: "long",
|
|
85
|
+
day: "numeric",
|
|
86
|
+
hour: "2-digit",
|
|
87
|
+
minute: "2-digit",
|
|
88
|
+
timeZone,
|
|
89
|
+
timeZoneName: "short",
|
|
84
90
|
}).format(epochMilliseconds);
|
|
85
91
|
}
|
|
86
|
-
function paragraphs(text) {
|
|
87
|
-
return text
|
|
88
|
-
.split(/\n{2,}/)
|
|
89
|
-
.map((part) => part.trim())
|
|
90
|
-
.filter((part) => part.length > 0)
|
|
91
|
-
.map((part) => ({ text: part }));
|
|
92
|
-
}
|
|
93
92
|
/** Structured mail content (header/sections/footer) for the email channel's
|
|
94
93
|
* renderer; the renderer owns HTML escaping, this only builds plain text. */
|
|
95
94
|
export function renderContractConfirmation(args) {
|
|
@@ -97,12 +96,16 @@ export function renderContractConfirmation(args) {
|
|
|
97
96
|
const labels = LABELS[locale];
|
|
98
97
|
const texts = CONSENT_TEXTS[locale];
|
|
99
98
|
const { consent } = args;
|
|
99
|
+
const { timeZone } = args;
|
|
100
|
+
const subjectLine = consent.mode === "payment"
|
|
101
|
+
? `${labels.item}: ${args.itemLabel ?? labels.oneOffPayment}`
|
|
102
|
+
: `${labels.plan}: ${args.tierLabel ?? consent.tier ?? labels.oneOffPayment}`;
|
|
100
103
|
const contractLines = [
|
|
101
|
-
|
|
104
|
+
subjectLine,
|
|
102
105
|
`${labels.price}: ${formatPrice(consent, locale)}`,
|
|
103
|
-
`${labels.contractStart}: ${formatDate(args.contractStartIso, locale)}`,
|
|
106
|
+
`${labels.contractStart}: ${formatDate(args.contractStartIso, locale, timeZone)}`,
|
|
104
107
|
...(args.currentPeriodEndIso !== undefined
|
|
105
|
-
? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale)}`]
|
|
108
|
+
? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale, timeZone)}`]
|
|
106
109
|
: []),
|
|
107
110
|
];
|
|
108
111
|
const vatNote = args.vatNote[locale] ?? args.vatNote[resolveConsentLocale()];
|
|
@@ -113,12 +116,12 @@ export function renderContractConfirmation(args) {
|
|
|
113
116
|
{ text: labels.intro },
|
|
114
117
|
...contractLines.map((text) => ({ text })),
|
|
115
118
|
...(vatNote !== undefined ? [{ text: vatNote }] : []),
|
|
116
|
-
{
|
|
119
|
+
{ heading: labels.consentHeading },
|
|
117
120
|
{ text: texts.earlyPerformance },
|
|
118
121
|
{ text: texts.withdrawalLoss },
|
|
119
|
-
{ text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale)}` },
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
+
{ text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale, timeZone)}` },
|
|
123
|
+
{ heading: labels.termsHeading },
|
|
124
|
+
{ markdown: args.termsContent },
|
|
122
125
|
],
|
|
123
126
|
footer: `${labels.contact} ${args.operatorEmail}`,
|
|
124
127
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import type { ConsumerProtectionOptions, ProviderPrice } from "../types.js";
|
|
4
4
|
export type CheckoutMode = "subscription" | "payment";
|
|
@@ -18,10 +18,15 @@ export type PreparedConsent = {
|
|
|
18
18
|
readonly termsHash: string;
|
|
19
19
|
readonly termsTemplateVersion: number;
|
|
20
20
|
};
|
|
21
|
+
export type OrderItem = {
|
|
22
|
+
readonly labelKey: string;
|
|
23
|
+
readonly params?: Readonly<Record<string, string | number>>;
|
|
24
|
+
};
|
|
21
25
|
export type ConsentPriceDetails = {
|
|
22
26
|
readonly tier: string | null;
|
|
23
27
|
readonly priceId: string;
|
|
24
28
|
readonly price: Pick<ProviderPrice, "unitAmount" | "currency" | "interval" | "intervalCount"> | null;
|
|
29
|
+
readonly orderItem?: OrderItem;
|
|
25
30
|
};
|
|
26
31
|
export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload | undefined, mode: CheckoutMode): Promise<PreparedConsent>;
|
|
27
32
|
export declare function consentCheckoutFields(prepared: PreparedConsent): {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Consent gate shared by start-plan-checkout and create-checkout-session:
|
|
2
2
|
// validate + prepare before the provider call, record only after it succeeded.
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
5
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
6
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
7
|
import * as z from "zod";
|
|
@@ -36,7 +37,7 @@ export async function prepareConsent(ctx, options, consent, mode) {
|
|
|
36
37
|
let terms;
|
|
37
38
|
try {
|
|
38
39
|
terms = await resolver.resolveTemplate({
|
|
39
|
-
tenantId:
|
|
40
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
40
41
|
slug: options.termsTextBlock,
|
|
41
42
|
kind: TEXT_BLOCK_KIND,
|
|
42
43
|
locale,
|
|
@@ -84,6 +85,10 @@ export async function recordConsent(ctx, prepared, details) {
|
|
|
84
85
|
termsTemplateVersion: prepared.termsTemplateVersion,
|
|
85
86
|
locale: prepared.locale,
|
|
86
87
|
actorUserId: String(ctx.user.id),
|
|
88
|
+
...(details.orderItem && {
|
|
89
|
+
itemLabelKey: details.orderItem.labelKey,
|
|
90
|
+
...(details.orderItem.params && { itemLabelParams: details.orderItem.params }),
|
|
91
|
+
}),
|
|
87
92
|
};
|
|
88
93
|
const tenantId = ctx.user.tenantId;
|
|
89
94
|
const isSubscription = prepared.mode === "subscription";
|