@cosmicdrift/kumiko-samples 0.339.0 → 0.341.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 +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +17 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +41 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
- package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
|
@@ -23,8 +23,15 @@ import {
|
|
|
23
23
|
access,
|
|
24
24
|
assignableAppRolesOf,
|
|
25
25
|
defineWriteHandler,
|
|
26
|
+
type HandlerContext,
|
|
27
|
+
type SessionUser,
|
|
28
|
+
type WriteResult,
|
|
26
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
InternalError,
|
|
32
|
+
type WriteFailure,
|
|
33
|
+
writeFailure,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
28
35
|
import { Temporal } from "temporal-polyfill";
|
|
29
36
|
import * as z from "zod";
|
|
30
37
|
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
|
|
@@ -40,14 +47,19 @@ import {
|
|
|
40
47
|
reservedMembershipRoleError,
|
|
41
48
|
unassignableMembershipRoleError,
|
|
42
49
|
} from "../../tenant/membership-roles.js";
|
|
50
|
+
// kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
|
|
51
|
+
import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
|
|
43
52
|
import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
|
|
44
53
|
import type { AuthMailLocale } from "../email-templates.js";
|
|
45
54
|
import { renderInviteEmail } from "../email-templates.js";
|
|
55
|
+
import { inviteAlreadyAccepted } from "../errors.js";
|
|
46
56
|
import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store.js";
|
|
47
57
|
import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
|
|
48
58
|
|
|
49
59
|
const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
|
|
50
60
|
|
|
61
|
+
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
62
|
+
|
|
51
63
|
const InviteCreateSchema = z.object({
|
|
52
64
|
email: z.email(),
|
|
53
65
|
role: z.string().min(1).max(50),
|
|
@@ -59,6 +71,7 @@ export type InviteCreateData = {
|
|
|
59
71
|
readonly tenantId: string;
|
|
60
72
|
readonly email: string;
|
|
61
73
|
readonly role: string;
|
|
74
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
62
75
|
readonly expiresAt: string;
|
|
63
76
|
};
|
|
64
77
|
|
|
@@ -97,15 +110,160 @@ function findRankedRole(roles: readonly string[]): string | undefined {
|
|
|
97
110
|
return roles.find((role) => findForbiddenRoleAssignment(["system"], [role], []) === undefined);
|
|
98
111
|
}
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
type InviteIssueRequest = {
|
|
114
|
+
readonly email: string;
|
|
115
|
+
readonly role: string;
|
|
116
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function checkAssignableInviteRole(
|
|
120
|
+
opts: InviteCreateOptions,
|
|
121
|
+
inviterRoles: readonly string[],
|
|
122
|
+
role: string,
|
|
123
|
+
ctx: HandlerContext,
|
|
124
|
+
): WriteFailure | undefined {
|
|
125
|
+
const forbiddenRole = findForbiddenMembershipRole([role]);
|
|
126
|
+
if (forbiddenRole !== undefined) return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
127
|
+
|
|
128
|
+
const elevationForbidden = findForbiddenRoleAssignment(
|
|
129
|
+
inviterRoles,
|
|
130
|
+
[role],
|
|
131
|
+
[],
|
|
132
|
+
assignableAppRolesOf(ctx.registry),
|
|
133
|
+
);
|
|
134
|
+
if (
|
|
135
|
+
elevationForbidden !== undefined &&
|
|
136
|
+
!opts.additionalAssignableRoles?.includes(elevationForbidden)
|
|
137
|
+
) {
|
|
138
|
+
return writeFailure(unassignableMembershipRoleError(elevationForbidden));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (opts.canAssignRole && !opts.canAssignRole(inviterRoles, role)) {
|
|
142
|
+
return writeFailure(unassignableMembershipRoleError(role));
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function issueInvitation(
|
|
148
|
+
opts: InviteCreateOptions,
|
|
149
|
+
ttlSeconds: number,
|
|
150
|
+
request: InviteIssueRequest,
|
|
151
|
+
inviter: SessionUser,
|
|
152
|
+
ctx: HandlerContext,
|
|
153
|
+
refuseAccepted = false,
|
|
154
|
+
): Promise<WriteResult<InviteCreateData>> {
|
|
155
|
+
if (!ctx.redis) {
|
|
156
|
+
return writeFailure(
|
|
157
|
+
new InternalError({ message: "invite-create requires ctx.redis for token store" }),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const email = request.email.toLowerCase();
|
|
162
|
+
const tenantId = inviter.tenantId;
|
|
163
|
+
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
164
|
+
|
|
165
|
+
// The unique index allows one row per (tenantId, email). Whatever its
|
|
166
|
+
// status, a re-invite resets it to pending with a fresh token.
|
|
167
|
+
const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
|
|
168
|
+
|
|
169
|
+
let invitationId: string;
|
|
170
|
+
if (existing) {
|
|
171
|
+
// Provisioning callers (waitlist) must not flip an accepted invite back to
|
|
172
|
+
// pending: that would let them cancel a live membership's invitation.
|
|
173
|
+
// The tenant-admin path keeps resetting because re-inviting a member is
|
|
174
|
+
// how roles get re-assigned.
|
|
175
|
+
if (refuseAccepted && existing["status"] === INVITATION_STATUS.accepted) {
|
|
176
|
+
return inviteAlreadyAccepted();
|
|
177
|
+
}
|
|
178
|
+
invitationId = existing["id"] as string; // @cast-boundary db-row
|
|
179
|
+
const existingVersion = existing["version"] as number; // @cast-boundary db-row
|
|
180
|
+
// At most one live invite token per invitation: invalidate
|
|
181
|
+
// whatever's there before minting the new one.
|
|
182
|
+
await invalidateExistingInviteToken(ctx.redis, invitationId);
|
|
183
|
+
|
|
184
|
+
const updateResult = await executor.update(
|
|
185
|
+
{
|
|
186
|
+
id: invitationId,
|
|
187
|
+
version: existingVersion,
|
|
188
|
+
changes: {
|
|
189
|
+
role: request.role,
|
|
190
|
+
// Always written, also as []: a tenant-admin resend must drop
|
|
191
|
+
// global roles a previous system invite put on this row.
|
|
192
|
+
globalRoles: [...request.globalRoles],
|
|
193
|
+
status: INVITATION_STATUS.pending,
|
|
194
|
+
invitedBy: inviter.id,
|
|
195
|
+
expiresAt,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
inviter,
|
|
199
|
+
ctx.db,
|
|
200
|
+
);
|
|
201
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
202
|
+
} else {
|
|
203
|
+
const createResult = await executor.create(
|
|
204
|
+
{
|
|
205
|
+
email,
|
|
206
|
+
role: request.role,
|
|
207
|
+
globalRoles: [...request.globalRoles],
|
|
208
|
+
status: INVITATION_STATUS.pending,
|
|
209
|
+
invitedBy: inviter.id,
|
|
210
|
+
expiresAt,
|
|
211
|
+
},
|
|
212
|
+
inviter,
|
|
213
|
+
ctx.db,
|
|
214
|
+
);
|
|
215
|
+
if (!createResult.isSuccess) return createResult;
|
|
216
|
+
invitationId = (createResult.data as { id: string }).id; // @cast-boundary engine-payload
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const token = generateToken();
|
|
220
|
+
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
221
|
+
|
|
222
|
+
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
223
|
+
|
|
224
|
+
await dispatchMagicLinkMail(
|
|
225
|
+
ctx.notify,
|
|
226
|
+
{
|
|
227
|
+
handlerName: "invite-create",
|
|
228
|
+
notificationType: INVITE_NOTIFICATION_TYPE,
|
|
229
|
+
renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: request.role }),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
email,
|
|
233
|
+
appUrl: opts.appUrl,
|
|
234
|
+
token,
|
|
235
|
+
expiresAt: expiresAt.toString(),
|
|
236
|
+
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
237
|
+
locale,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
isSuccess: true,
|
|
243
|
+
data: {
|
|
244
|
+
kind: "invite-created",
|
|
245
|
+
invitationId,
|
|
246
|
+
tenantId,
|
|
247
|
+
email,
|
|
248
|
+
role: request.role,
|
|
249
|
+
globalRoles: request.globalRoles,
|
|
250
|
+
expiresAt: expiresAt.toString(),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function assertAppDefinedAdditionalRoles(opts: InviteCreateOptions): void {
|
|
101
256
|
const rankedRole = findRankedRole(opts.additionalAssignableRoles ?? []);
|
|
102
257
|
if (rankedRole !== undefined) {
|
|
103
258
|
throw new Error(
|
|
104
259
|
`[auth-email-password] invite.additionalAssignableRoles must list app-defined roles only; "${rankedRole}" is a framework-ranked role`,
|
|
105
260
|
);
|
|
106
261
|
}
|
|
107
|
-
|
|
108
|
-
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
265
|
+
assertAppDefinedAdditionalRoles(opts);
|
|
266
|
+
const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
|
|
109
267
|
|
|
110
268
|
return defineWriteHandler<"invite-create", typeof InviteCreateSchema, InviteCreateData>({
|
|
111
269
|
name: "invite-create",
|
|
@@ -114,119 +272,58 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
114
272
|
description:
|
|
115
273
|
"Invites an email address into the caller's tenant with a chosen membership role, creating or reusing the invitation and mailing the invitee a fresh accept link; re-inviting the same address invalidates the previous link.",
|
|
116
274
|
handler: async (event, ctx) => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
new InternalError({ message: "invite-create requires ctx.redis for token store" }),
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const forbiddenRole = findForbiddenMembershipRole([event.payload.role]);
|
|
124
|
-
if (forbiddenRole !== undefined) {
|
|
125
|
-
return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const elevationForbidden = findForbiddenRoleAssignment(
|
|
275
|
+
const roleRejection = checkAssignableInviteRole(
|
|
276
|
+
opts,
|
|
129
277
|
event.user.roles,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
assignableAppRolesOf(ctx.registry),
|
|
278
|
+
event.payload.role,
|
|
279
|
+
ctx,
|
|
133
280
|
);
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
|
|
142
|
-
return writeFailure(unassignableMembershipRoleError(event.payload.role));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const email = event.payload.email.toLowerCase();
|
|
146
|
-
const tenantId = event.user.tenantId;
|
|
147
|
-
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
148
|
-
|
|
149
|
-
// Existing row für (tenantId, email) — unique-index garantiert
|
|
150
|
-
// max. eine Row. Status egal (cancelled/accepted/expired/pending);
|
|
151
|
-
// wir setzen sie auf pending zurück und vergeben einen frischen
|
|
152
|
-
// Token wenn der bisherige nicht mehr lebt.
|
|
153
|
-
const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
|
|
154
|
-
|
|
155
|
-
let invitationId: string;
|
|
156
|
-
let token: string;
|
|
157
|
-
if (existing) {
|
|
158
|
-
invitationId = existing["id"] as string; // @cast-boundary db-row
|
|
159
|
-
const existingVersion = existing["version"] as number; // @cast-boundary db-row
|
|
160
|
-
// At most one live invite token per invitation: invalidate
|
|
161
|
-
// whatever's there before minting the new one.
|
|
162
|
-
await invalidateExistingInviteToken(ctx.redis, invitationId);
|
|
163
|
-
token = generateToken();
|
|
164
|
-
|
|
165
|
-
const updateResult = await executor.update(
|
|
166
|
-
{
|
|
167
|
-
id: invitationId,
|
|
168
|
-
version: existingVersion,
|
|
169
|
-
changes: {
|
|
170
|
-
role: event.payload.role,
|
|
171
|
-
status: INVITATION_STATUS.pending,
|
|
172
|
-
invitedBy: event.user.id,
|
|
173
|
-
expiresAt,
|
|
174
|
-
},
|
|
175
|
-
},
|
|
176
|
-
event.user,
|
|
177
|
-
ctx.db,
|
|
178
|
-
);
|
|
179
|
-
if (!updateResult.isSuccess) return updateResult;
|
|
180
|
-
} else {
|
|
181
|
-
const createResult = await executor.create(
|
|
182
|
-
{
|
|
183
|
-
email,
|
|
184
|
-
role: event.payload.role,
|
|
185
|
-
status: INVITATION_STATUS.pending,
|
|
186
|
-
invitedBy: event.user.id,
|
|
187
|
-
expiresAt,
|
|
188
|
-
},
|
|
189
|
-
event.user,
|
|
190
|
-
ctx.db,
|
|
191
|
-
);
|
|
192
|
-
if (!createResult.isSuccess) return createResult;
|
|
193
|
-
invitationId = (createResult.data as { id: string }).id; // @cast-boundary engine-payload
|
|
194
|
-
token = generateToken();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
198
|
-
|
|
199
|
-
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
200
|
-
|
|
201
|
-
await dispatchMagicLinkMail(
|
|
202
|
-
ctx.notify,
|
|
203
|
-
{
|
|
204
|
-
handlerName: "invite-create",
|
|
205
|
-
notificationType: INVITE_NOTIFICATION_TYPE,
|
|
206
|
-
renderContent: (renderArgs) =>
|
|
207
|
-
renderInviteEmail({ ...renderArgs, role: event.payload.role }),
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
email,
|
|
211
|
-
appUrl: opts.appUrl,
|
|
212
|
-
token,
|
|
213
|
-
expiresAt: expiresAt.toString(),
|
|
214
|
-
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
215
|
-
locale,
|
|
216
|
-
},
|
|
281
|
+
if (roleRejection) return roleRejection;
|
|
282
|
+
return issueInvitation(
|
|
283
|
+
opts,
|
|
284
|
+
ttlSeconds,
|
|
285
|
+
{ email: event.payload.email, role: event.payload.role, globalRoles: [] },
|
|
286
|
+
event.user,
|
|
287
|
+
ctx,
|
|
217
288
|
);
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
218
292
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
293
|
+
const SystemInviteCreateSchema = z.object({
|
|
294
|
+
email: z.email(),
|
|
295
|
+
role: z.string().min(1).max(50),
|
|
296
|
+
globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// System-only sibling of invite-create: the only path that can put global
|
|
300
|
+
// roles (SystemAdmin) on an invitation. No session mint path yields the
|
|
301
|
+
// "system" role (users.roles only accepts GLOBAL_ROLE_OPTIONS), so only
|
|
302
|
+
// in-process callers dispatching as createSystemUser (runBootstrap) get here.
|
|
303
|
+
export function createSystemInviteCreateHandler(opts: InviteCreateOptions) {
|
|
304
|
+
assertAppDefinedAdditionalRoles(opts);
|
|
305
|
+
const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
|
|
306
|
+
|
|
307
|
+
return defineWriteHandler<
|
|
308
|
+
"system-invite-create",
|
|
309
|
+
typeof SystemInviteCreateSchema,
|
|
310
|
+
InviteCreateData
|
|
311
|
+
>({
|
|
312
|
+
name: "system-invite-create",
|
|
313
|
+
schema: SystemInviteCreateSchema,
|
|
314
|
+
access: { roles: access.system },
|
|
315
|
+
agent: { expose: false },
|
|
316
|
+
description:
|
|
317
|
+
"System-only invite into the dispatching tenant that may additionally carry global roles, which the invitee receives only when accepting; used by provisioning such as runBootstrap.",
|
|
318
|
+
handler: async (event, ctx) => {
|
|
319
|
+
const roleRejection = checkAssignableInviteRole(
|
|
320
|
+
opts,
|
|
321
|
+
event.user.roles,
|
|
322
|
+
event.payload.role,
|
|
323
|
+
ctx,
|
|
324
|
+
);
|
|
325
|
+
if (roleRejection) return roleRejection;
|
|
326
|
+
return issueInvitation(opts, ttlSeconds, event.payload, event.user, ctx, true);
|
|
230
327
|
},
|
|
231
328
|
});
|
|
232
329
|
}
|
package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts
CHANGED
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
} from "../../tenant/membership-roles.js";
|
|
47
47
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
|
|
48
48
|
import { seedTenantMembership } from "../../tenant/seeding.js";
|
|
49
|
+
// kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
|
|
50
|
+
import { parseInvitedGlobalRoles } from "../../user/invited-global-roles.js";
|
|
49
51
|
// kumiko-lint-ignore cross-feature-import existence-check
|
|
50
52
|
import { userTable } from "../../user/schema/user.js";
|
|
51
53
|
import { invalidInviteToken } from "../errors.js";
|
|
@@ -113,6 +115,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
113
115
|
readonly tenantId: TenantId;
|
|
114
116
|
readonly email: string;
|
|
115
117
|
readonly role: string;
|
|
118
|
+
readonly globalRoles: unknown;
|
|
116
119
|
readonly version: number;
|
|
117
120
|
};
|
|
118
121
|
|
|
@@ -133,6 +136,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
133
136
|
"auth:invite-signup-complete",
|
|
134
137
|
);
|
|
135
138
|
const invitationRole = invitation.role;
|
|
139
|
+
const invitationGlobalRoles = parseInvitedGlobalRoles(invitation.globalRoles);
|
|
136
140
|
const invitationVersion = invitation.version;
|
|
137
141
|
|
|
138
142
|
// User-Not-Exists-Check: wenn die Email schon registriert ist,
|
|
@@ -157,6 +161,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
157
161
|
password: event.payload.password,
|
|
158
162
|
displayName: invitationEmail.split("@")[0] ?? invitationEmail,
|
|
159
163
|
emailVerified: true,
|
|
164
|
+
...(invitationGlobalRoles.length > 0 && { roles: invitationGlobalRoles }),
|
|
160
165
|
});
|
|
161
166
|
|
|
162
167
|
// Membership-Add via seed-helper (gleiches Pattern wie
|
|
@@ -192,7 +197,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
192
197
|
tenantId: invitationTenantId,
|
|
193
198
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
194
199
|
// a reserved role on the invitation itself must never reach the session.
|
|
195
|
-
roles: buildSessionRoles(
|
|
200
|
+
roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
|
|
196
201
|
};
|
|
197
202
|
|
|
198
203
|
committed = true;
|
|
@@ -93,6 +93,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
93
93
|
schema: SignupRequestSchema,
|
|
94
94
|
access: { roles: ["anonymous"] },
|
|
95
95
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
96
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
|
|
96
97
|
description:
|
|
97
98
|
"Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
|
|
98
99
|
escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
|
|
@@ -100,6 +100,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
100
100
|
schema: RequestTokenSchema,
|
|
101
101
|
access: { roles: ["anonymous"] },
|
|
102
102
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
103
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
|
|
103
104
|
escapeHatch: {
|
|
104
105
|
reason:
|
|
105
106
|
"Anonymous requester has no session — looks up the target user by email via " +
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
// its own changeset + deliberate deprecation window, not a silent drop.
|
|
8
8
|
export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
|
|
9
9
|
export { type AuthPath, type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
|
|
10
|
+
export type {
|
|
11
|
+
BootstrapConfigValue,
|
|
12
|
+
BootstrapDeps,
|
|
13
|
+
BootstrapInvite,
|
|
14
|
+
BootstrapInviteOutcome,
|
|
15
|
+
BootstrapPlan,
|
|
16
|
+
BootstrapReport,
|
|
17
|
+
BootstrapSeedDeps,
|
|
18
|
+
BootstrapSystemAdmin,
|
|
19
|
+
BootstrapTenant,
|
|
20
|
+
BootstrapTenantOutcome,
|
|
21
|
+
} from "./bootstrap.js";
|
|
22
|
+
export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
|
|
10
23
|
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
|
|
11
24
|
// Renderers for the auth mails. All four magic-link flows (reset, verify,
|
|
12
25
|
// signup-activation, invite) emit structured AuthMailContent through delivery
|
|
@@ -76,3 +76,9 @@ export async function deleteInviteToken(
|
|
|
76
76
|
/** Burn release for failed-accept paths (DB error etc.) so a legitimate
|
|
77
77
|
* retry isn't blocked by a stale burn marker. */
|
|
78
78
|
export const unburnInviteToken = store.unburn;
|
|
79
|
+
|
|
80
|
+
/** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
|
|
81
|
+
* is the single truth for invite expiry (see the module comment). */
|
|
82
|
+
export async function hasLiveInviteToken(redis: Redis, invitationId: string): Promise<boolean> {
|
|
83
|
+
return (await store.getTokenHashForSubject(redis, invitationId)) !== null;
|
|
84
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "MFA required policy is writable by the system only",
|
|
6
|
+
"migration": "Tenant admins can no longer write auth-mfa:config:required (write access is now system-only). Set the app-wide default with createAuthMfaFeature({ requiredPolicy }) / the mfa options and per-tenant overrides with runBootstrap tenants[].config or a system write (createSystemUser). Code that wrote the key as TenantAdmin/Admin/SystemAdmin over HTTP must switch to one of these."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
// UI). Flipping this away from "optional" before PR3 locks out every
|
|
9
9
|
// unenrolled matching user with no recovery path. Fine for a tenant that
|
|
10
10
|
// enrolls its admins out-of-band first; a footgun otherwise.
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
access,
|
|
13
|
+
type ConfigKeyHandle,
|
|
14
|
+
createTenantConfig,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
16
|
|
|
13
17
|
export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"] as const;
|
|
14
18
|
export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
|
|
@@ -18,9 +22,14 @@ export const mfaRequiredConfigHandle: ConfigKeyHandle<"select"> = {
|
|
|
18
22
|
type: "select",
|
|
19
23
|
};
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
// Write access is machine-only: a TenantAdmin must not be able to relax the
|
|
26
|
+
// policy back to "optional". The app-wide default comes from
|
|
27
|
+
// `createAuthMfaFeature({ requiredPolicy })`, per-tenant overrides are set by
|
|
28
|
+
// system writes (runBootstrap `tenants[].config`, jobs, SystemAdmin tooling).
|
|
29
|
+
export function mfaRequiredConfigKey(defaultPolicy: MfaRequiredPolicy = "optional") {
|
|
22
30
|
return createTenantConfig("select", {
|
|
23
|
-
default:
|
|
31
|
+
default: defaultPolicy,
|
|
24
32
|
options: [...MFA_REQUIRED_POLICIES],
|
|
33
|
+
write: access.system,
|
|
25
34
|
});
|
|
26
35
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { mfaRequiredConfigKey } from "./config.js";
|
|
3
|
+
import { type MfaRequiredPolicy, mfaRequiredConfigKey } from "./config.js";
|
|
4
4
|
import { createDisableHandler } from "./handlers/disable.write.js";
|
|
5
5
|
import { createEnableConfirmHandler } from "./handlers/enable-confirm.write.js";
|
|
6
6
|
import { createEnableConfirmPreauthHandler } from "./handlers/enable-confirm-preauth.write.js";
|
|
@@ -33,6 +33,10 @@ export type AuthMfaFeatureOptions = {
|
|
|
33
33
|
// secret from setupTokenSecret — a compromised setup-token secret must
|
|
34
34
|
// not also forge login challenges.
|
|
35
35
|
readonly challengeTokenSecret: string;
|
|
36
|
+
// App-wide MFA enforcement default when no tenant row overrides it.
|
|
37
|
+
// Tenant admins cannot change the policy (system-only write), so this plus
|
|
38
|
+
// system writes is the only way to set it. Defaults to "optional".
|
|
39
|
+
readonly requiredPolicy?: MfaRequiredPolicy;
|
|
36
40
|
};
|
|
37
41
|
|
|
38
42
|
export type BindMfaRevokeAllOtherSessions = (
|
|
@@ -127,7 +131,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
127
131
|
// the app boots clean and the first MFA login dies at runtime on an
|
|
128
132
|
// unknown handler instead of failing at mount time.
|
|
129
133
|
r.requires("tenant");
|
|
130
|
-
r.config("required", mfaRequiredConfigKey());
|
|
134
|
+
r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
|
|
131
135
|
|
|
132
136
|
// Declarative secretMint screen — mint (no input) -> reveal QR/secret/
|
|
133
137
|
// recovery-codes -> confirm with a code. App places it via r.nav in its
|
|
@@ -225,6 +229,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
225
229
|
// at app-boot time.
|
|
226
230
|
const checkMfaStatus = createMfaStatusChecker({
|
|
227
231
|
challengeTokenSecret: opts.challengeTokenSecret,
|
|
232
|
+
...(opts.requiredPolicy && { requiredPolicy: opts.requiredPolicy }),
|
|
228
233
|
});
|
|
229
234
|
|
|
230
235
|
// No late-bind needed — same reasoning as checkMfaStatus: only needs the
|
|
@@ -40,6 +40,7 @@ export type MfaStatusChecker = (
|
|
|
40
40
|
|
|
41
41
|
export function createMfaStatusChecker(opts: {
|
|
42
42
|
readonly challengeTokenSecret: string;
|
|
43
|
+
readonly requiredPolicy?: MfaRequiredPolicy;
|
|
43
44
|
}): MfaStatusChecker {
|
|
44
45
|
return async (ctx, userId, tenantId, roles) => {
|
|
45
46
|
declareEscapeHatch({
|
|
@@ -70,7 +71,7 @@ export function createMfaStatusChecker(opts: {
|
|
|
70
71
|
// against the declared `options` at write-time by the config feature.
|
|
71
72
|
const policy = ((await ctx.configResolver?.get(
|
|
72
73
|
mfaRequiredConfigHandle.name,
|
|
73
|
-
mfaRequiredConfigKey(),
|
|
74
|
+
mfaRequiredConfigKey(opts.requiredPolicy),
|
|
74
75
|
tenantId,
|
|
75
76
|
userId,
|
|
76
77
|
scopedDb,
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "App layout around termination pages, trailing-slash redirects, receipt greeting",
|
|
6
|
+
"detail": "`createContractTerminationRoutes` accepts a `wrapLayout`, the same function `createLegalPagesFeature` takes. It wraps every termination page (form, review, result, 429, error); the security headers stay with the framework. The layout also receives `alternates`, a map from locale to the path of the same page, so it can render a language switch from plain links. `page-render` exports the shared type `PublicPageWrapLayout`; `LegalPagesWrapLayout` is now an alias of it. The page body sits in `<div data-kumiko-page=\"contract-termination\">` with `data-kumiko-*` hooks on the form, field groups, buttons and review table. The page CSP keeps `script-src 'none'`, so a layout must not rely on JavaScript.\nThe default `wrapInLayout` stylesheet gains base rules for input, select, textarea and button, so unstyled forms stay readable.\nThe termination pages and every legal page now answer the other trailing-slash form of their path (`/legal/kuendigen/`, `/legal/impressum/`) with a 301 to the configured path for GET and HEAD, query string kept. POST has no alias.\nThe termination receipt mail text changed: it now starts with a greeting line (\"Hallo,\" / \"Hello,\") and the English sentence starts capitalized (\"We confirm receipt ...\"). The receipt content still depends only on the declarant's input."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.340.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Payment and subscription projections use typed bun-db helpers",
|
|
12
|
+
"detail": "`payment-received` inserts through `insertOnConflictDoNothing`, subscription events upsert through `upsertOnConflict`. The raw `db/queries/payment-projection.ts` and `subscription-projection.ts` modules are gone.",
|
|
13
|
+
"migration": "keine"
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.339.0",
|
|
4
17
|
"type": "improvement",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts
CHANGED
|
@@ -87,6 +87,7 @@ export function renderTerminationReceipt(
|
|
|
87
87
|
subject,
|
|
88
88
|
header: subject,
|
|
89
89
|
sections: [
|
|
90
|
+
{ text: texts.receiptGreeting },
|
|
90
91
|
{ text: texts.receiptIntro },
|
|
91
92
|
{ text: `${texts.receivedAt}: ${formatReceivedAt(args.receivedAtIso, args.locale)}` },
|
|
92
93
|
{ text: `${texts.requestId}: ${args.requestId}` },
|