@cosmicdrift/kumiko-samples 0.340.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.
Files changed (104) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
  4. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  5. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  6. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  14. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  15. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  16. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  17. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  18. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  19. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  28. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  29. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  30. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  31. package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
  32. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  33. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  34. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  35. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  36. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  37. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  38. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  39. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  40. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  41. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  42. package/packages/bundled-features/src/delivery/changes.json +7 -0
  43. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  44. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  45. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  46. package/packages/bundled-features/src/delivery/index.ts +4 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  48. package/packages/bundled-features/src/delivery/types.ts +10 -4
  49. package/packages/bundled-features/src/files/changes.json +13 -1
  50. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  51. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  52. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  53. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  54. package/packages/bundled-features/src/page-render/index.ts +2 -0
  55. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  56. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  57. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  58. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  59. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  60. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  61. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  62. package/packages/bundled-features/src/secrets/changes.json +12 -0
  63. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  64. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  65. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  66. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  67. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  68. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  69. package/packages/bundled-features/src/tenant/changes.json +6 -0
  70. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  71. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  72. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  73. package/packages/bundled-features/src/tenant/index.ts +1 -0
  74. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  75. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  76. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  77. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  78. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  79. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  80. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  81. package/packages/bundled-features/src/waitlist/changes.json +17 -0
  82. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  83. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  84. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  85. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  86. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  87. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  88. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  89. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  90. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  91. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  92. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  93. package/packages/bundled-features/src/waitlist/options.ts +41 -0
  94. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  95. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  96. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  97. package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
  98. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  99. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  100. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  101. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  102. package/samples/recipes/chat-channels/README.md +2 -0
  103. package/samples/recipes/managed-config/README.md +1 -1
  104. package/samples/recipes/rate-limiting/README.md +21 -0
@@ -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 { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
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
- export function createInviteCreateHandler(opts: InviteCreateOptions) {
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
- const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
108
- const ttlSeconds = ttlMinutes * 60;
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
- if (!ctx.redis) {
118
- return writeFailure(
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
- [event.payload.role],
131
- [],
132
- assignableAppRolesOf(ctx.registry),
278
+ event.payload.role,
279
+ ctx,
133
280
  );
134
- if (
135
- elevationForbidden !== undefined &&
136
- !opts.additionalAssignableRoles?.includes(elevationForbidden)
137
- ) {
138
- return writeFailure(unassignableMembershipRoleError(elevationForbidden));
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
- return {
220
- isSuccess: true,
221
- data: {
222
- kind: "invite-created",
223
- invitationId,
224
- tenantId,
225
- email,
226
- role: event.payload.role,
227
- expiresAt: expiresAt.toString(),
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
  }
@@ -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([], [invitationRole]),
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
+ }
@@ -82,6 +82,7 @@ export async function dispatchMagicLinkMail(
82
82
  await notify(spec.notificationType, {
83
83
  route: { email: params.email },
84
84
  data: content,
85
+ locale,
85
86
  priority: "critical",
86
87
  });
87
88
  }
@@ -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 { type ConfigKeyHandle, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
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
- export function mfaRequiredConfigKey() {
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: "optional" satisfies MfaRequiredPolicy,
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,10 @@
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
+ },
2
8
  {
3
9
  "version": "0.340.0",
4
10
  "type": "improvement",
@@ -188,6 +188,7 @@ export function createIssueContractConfirmationHandler(
188
188
  await ctx.notify(CONTRACT_CONFIRMATION_NOTIFICATION_TYPE, {
189
189
  route: { email: recipient.data.email },
190
190
  data: content,
191
+ locale,
191
192
  priority: "critical",
192
193
  });
193
194
 
@@ -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}` },
@@ -39,6 +39,7 @@ export async function notifyOperator(
39
39
  ...plan,
40
40
  operatorEmail: consumerProtection.operatorEmail,
41
41
  }),
42
+ locale: plan.locale,
42
43
  priority: "critical",
43
44
  });
44
45
  }