@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.340.0",
3
+ "version": "0.341.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.340.0",
3
+ "version": "0.341.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -132,6 +132,7 @@
132
132
  "./renderer-foundation": "./src/renderer-foundation/index.ts",
133
133
  "./legal-pages": "./src/legal-pages/index.ts",
134
134
  "./legal-pages/web": "./src/legal-pages/web/index.ts",
135
+ "./waitlist": "./src/waitlist/index.ts",
135
136
  "./page-render": "./src/page-render/index.ts",
136
137
  "./page-render/web": "./src/page-render/web.ts",
137
138
  "./managed-pages": "./src/managed-pages/index.ts",
@@ -618,6 +619,10 @@
618
619
  "types": "./dist/legal-pages/web/index.d.ts",
619
620
  "default": "./dist/legal-pages/web/index.js"
620
621
  },
622
+ "./waitlist": {
623
+ "types": "./dist/waitlist/index.d.ts",
624
+ "default": "./dist/waitlist/index.js"
625
+ },
621
626
  "./page-render": {
622
627
  "types": "./dist/page-render/index.d.ts",
623
628
  "default": "./dist/page-render/index.js"
@@ -0,0 +1,309 @@
1
+ // Passwordless, idempotent first-run provisioning: tenants plus invitations
2
+ // (optionally carrying global roles such as SystemAdmin). Every write goes
3
+ // through the dispatcher as the system user, so hooks, events and jobs fire
4
+ // exactly as for any other writer. Nobody gets a role before accepting: the
5
+ // invitation row holds membership role + global roles until the invitee
6
+ // accepts through the regular invite-accept routes.
7
+
8
+ import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
9
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ ConfigScopes,
13
+ createSystemUser,
14
+ type TenantId,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
17
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
18
+ import type { Redis } from "ioredis";
19
+ // kumiko-lint-ignore cross-feature-import bootstrap sets per-tenant config through the config feature's write handler
20
+ import { ConfigHandlers } from "../config/constants.js";
21
+ // kumiko-lint-ignore cross-feature-import bootstrap compares against the stored config row to stay idempotent
22
+ import { findConfigRow } from "../config/write-helpers.js";
23
+ // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
24
+ import { TenantHandlers } from "../tenant/constants.js";
25
+ // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
26
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../tenant/invitation-table.js";
27
+ // kumiko-lint-ignore cross-feature-import bootstrap checks existing memberships
28
+ import { tenantMembershipsTable } from "../tenant/membership-table.js";
29
+ // kumiko-lint-ignore cross-feature-import bootstrap checks for an existing tenant
30
+ import { tenantTable } from "../tenant/schema/tenant.js";
31
+ // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
32
+ import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
33
+ // kumiko-lint-ignore cross-feature-import bootstrap skips already activated users
34
+ import { userTable } from "../user/schema/user.js";
35
+ import { AuthHandlers } from "./constants.js";
36
+ import { hasLiveInviteToken } from "./invite-token-store.js";
37
+
38
+ type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
39
+
40
+ export type BootstrapInvite = {
41
+ readonly email: string;
42
+ /** Membership role in the tenant, validated like a tenant-admin invite
43
+ * (reserved roles such as SystemAdmin are rejected here). */
44
+ readonly role: string;
45
+ };
46
+
47
+ export type BootstrapSeedDeps = {
48
+ readonly tenantId: TenantId;
49
+ readonly db: DbConnection;
50
+ readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
51
+ };
52
+
53
+ export type BootstrapConfigValue = string | number | boolean;
54
+
55
+ export type BootstrapTenant = {
56
+ readonly id: TenantId;
57
+ readonly key: string;
58
+ readonly name: string;
59
+ /** Tenant-scope config values by qualified key (e.g.
60
+ * `"auth-mfa:config:required": "admins"`), written as system writes on
61
+ * EVERY run: config is declarative, so a rerun converges to the plan and
62
+ * a value that already matches is not rewritten. Unlike `seed` it also
63
+ * applies to tenants that already exist. */
64
+ readonly config?: Readonly<Record<string, BootstrapConfigValue>>;
65
+ readonly invites?: readonly BootstrapInvite[];
66
+ };
67
+
68
+ export type BootstrapSystemAdmin = {
69
+ readonly email: string;
70
+ /** Home tenant: login needs a membership, so the SystemAdmin is invited
71
+ * into this tenant with `role` and receives the global role on accept. */
72
+ readonly tenantId: TenantId;
73
+ readonly role: string;
74
+ };
75
+
76
+ export type BootstrapPlan = {
77
+ readonly tenants: readonly BootstrapTenant[];
78
+ readonly systemAdmins?: readonly BootstrapSystemAdmin[];
79
+ /** Runs for each tenant right after this run created it. An existing
80
+ * tenant is never seeded again — also not when a previous run failed
81
+ * inside the seed (that run fails; clean up by hand). */
82
+ readonly seed?: (deps: BootstrapSeedDeps) => Promise<void>;
83
+ };
84
+
85
+ export type BootstrapDeps = {
86
+ readonly db: DbConnection;
87
+ readonly redis: Redis;
88
+ readonly dispatcher: Dispatcher;
89
+ };
90
+
91
+ export type BootstrapTenantOutcome = "created" | "exists";
92
+
93
+ export type BootstrapInviteOutcome =
94
+ // fresh invitation mailed
95
+ | "invited"
96
+ // the previous invitation expired unused, a new link was mailed
97
+ | "resent"
98
+ // a still-valid invitation is pending, nothing sent
99
+ | "pending"
100
+ // the invitee accepted earlier or already holds the access
101
+ | "active"
102
+ // a tenant admin cancelled the invitation; bootstrap respects that
103
+ | "cancelled"
104
+ // a tenant admin re-invited the address without the planned global roles;
105
+ // bootstrap does not override that, the operator has to decide
106
+ | "role-mismatch";
107
+
108
+ export type BootstrapReport = {
109
+ readonly tenants: ReadonlyArray<{
110
+ readonly id: TenantId;
111
+ readonly outcome: BootstrapTenantOutcome;
112
+ readonly seeded: boolean;
113
+ /** Qualified keys this run wrote (new or changed); empty on a no-op rerun. */
114
+ readonly configApplied: readonly string[];
115
+ }>;
116
+ readonly invites: ReadonlyArray<{
117
+ readonly tenantId: TenantId;
118
+ readonly email: string;
119
+ readonly globalRoles: readonly GlobalRole[];
120
+ readonly outcome: BootstrapInviteOutcome;
121
+ }>;
122
+ };
123
+
124
+ type PlannedInvite = {
125
+ readonly tenantId: TenantId;
126
+ readonly email: string;
127
+ readonly role: string;
128
+ readonly globalRoles: readonly GlobalRole[];
129
+ };
130
+
131
+ type InvitationRow = {
132
+ readonly id: string;
133
+ readonly status: string;
134
+ readonly globalRoles: unknown;
135
+ };
136
+ type UserRow = { readonly id: string; readonly roles: unknown };
137
+
138
+ export class BootstrapPlanError extends Error {}
139
+
140
+ export class BootstrapWriteError extends Error {}
141
+
142
+ function planInvites(plan: BootstrapPlan): readonly PlannedInvite[] {
143
+ const tenantIds = new Set(plan.tenants.map((t) => t.id));
144
+ const invites: PlannedInvite[] = [];
145
+ for (const admin of plan.systemAdmins ?? []) {
146
+ if (!tenantIds.has(admin.tenantId)) {
147
+ throw new BootstrapPlanError(
148
+ `systemAdmins: ${admin.email} references tenant ${admin.tenantId}, which is not in tenants`,
149
+ );
150
+ }
151
+ invites.push({
152
+ tenantId: admin.tenantId,
153
+ email: admin.email.toLowerCase(),
154
+ role: admin.role,
155
+ globalRoles: ["SystemAdmin"],
156
+ });
157
+ }
158
+ for (const tenant of plan.tenants) {
159
+ for (const invite of tenant.invites ?? []) {
160
+ invites.push({
161
+ tenantId: tenant.id,
162
+ email: invite.email.toLowerCase(),
163
+ role: invite.role,
164
+ globalRoles: [],
165
+ });
166
+ }
167
+ }
168
+ // One invitation row per (tenant, email): a second entry would overwrite
169
+ // the first one's role or global roles.
170
+ const seen = new Set<string>();
171
+ for (const invite of invites) {
172
+ const key = `${invite.tenantId}\u0000${invite.email}`;
173
+ if (seen.has(key)) {
174
+ throw new BootstrapPlanError(
175
+ `${invite.email} is listed twice for tenant ${invite.tenantId} (systemAdmins and tenants[].invites share one invitation per tenant)`,
176
+ );
177
+ }
178
+ seen.add(key);
179
+ }
180
+ return invites;
181
+ }
182
+
183
+ async function ensureTenant(
184
+ deps: BootstrapDeps,
185
+ tenant: BootstrapTenant,
186
+ ): Promise<BootstrapTenantOutcome> {
187
+ const existing = await fetchOne(deps.db, tenantTable, { id: tenant.id });
188
+ if (existing) return "exists";
189
+ const result = await deps.dispatcher.write(
190
+ TenantHandlers.create,
191
+ { id: tenant.id, key: tenant.key, name: tenant.name },
192
+ createSystemUser(tenant.id),
193
+ );
194
+ if (!result.isSuccess) {
195
+ throw new BootstrapWriteError(
196
+ `creating tenant ${tenant.key} (${tenant.id}) failed: ${result.error.code} — ${result.error.message}`,
197
+ );
198
+ }
199
+ return "created";
200
+ }
201
+
202
+ async function applyTenantConfig(
203
+ deps: BootstrapDeps,
204
+ tenant: BootstrapTenant,
205
+ ): Promise<readonly string[]> {
206
+ const applied: string[] = [];
207
+ for (const [key, value] of Object.entries(tenant.config ?? {})) {
208
+ const existing = await findConfigRow(deps.db, key, tenant.id, null);
209
+ if (existing?.value === JSON.stringify(value)) continue;
210
+ const result = await deps.dispatcher.write(
211
+ ConfigHandlers.set,
212
+ { key, value, scope: ConfigScopes.tenant },
213
+ // SystemAdmin on top of the system role: keys with the default tenant
214
+ // write access (admin roles) reject a bare system user, system-only keys
215
+ // accept it.
216
+ createSystemUser(tenant.id, ["SystemAdmin"]),
217
+ );
218
+ if (!result.isSuccess) {
219
+ throw new BootstrapWriteError(
220
+ `setting config ${key} for tenant ${tenant.id} failed: ${result.error.code} — ${result.error.message}`,
221
+ );
222
+ }
223
+ applied.push(key);
224
+ }
225
+ return applied;
226
+ }
227
+
228
+ async function holdsInvitedAccess(deps: BootstrapDeps, invite: PlannedInvite): Promise<boolean> {
229
+ const user = await fetchOne<UserRow>(deps.db, userTable, {
230
+ email: invite.email,
231
+ isDeleted: false,
232
+ });
233
+ if (!user) return false;
234
+ const membership = await fetchOne(deps.db, tenantMembershipsTable, {
235
+ userId: user.id,
236
+ tenantId: invite.tenantId,
237
+ });
238
+ if (!membership) return false;
239
+ const userGlobalRoles = parseRoles(user.roles);
240
+ return invite.globalRoles.every((role) => userGlobalRoles.includes(role));
241
+ }
242
+
243
+ async function decideInvite(
244
+ deps: BootstrapDeps,
245
+ invite: PlannedInvite,
246
+ ): Promise<BootstrapInviteOutcome> {
247
+ if (await holdsInvitedAccess(deps, invite)) return "active";
248
+ const invitation = await fetchOne<InvitationRow>(deps.db, tenantInvitationsTable, {
249
+ tenantId: invite.tenantId,
250
+ email: invite.email,
251
+ });
252
+ if (!invitation) return "invited";
253
+ if (invitation.status === INVITATION_STATUS.cancelled) return "cancelled";
254
+ const storedGlobalRoles = parseRoles(invitation.globalRoles);
255
+ if (!invite.globalRoles.every((role) => storedGlobalRoles.includes(role))) {
256
+ return "role-mismatch";
257
+ }
258
+ if (invitation.status === INVITATION_STATUS.accepted) return "active";
259
+ if (await hasLiveInviteToken(deps.redis, invitation.id)) return "pending";
260
+ return "resent";
261
+ }
262
+
263
+ function isMailingOutcome(outcome: BootstrapInviteOutcome): boolean {
264
+ return outcome === "invited" || outcome === "resent";
265
+ }
266
+
267
+ export async function bootstrapTenants(
268
+ deps: BootstrapDeps,
269
+ plan: BootstrapPlan,
270
+ ): Promise<BootstrapReport> {
271
+ const plannedInvites = planInvites(plan);
272
+ const dispatchSystemWrite = makeDispatchSystemWrite(deps.dispatcher);
273
+
274
+ const tenants: Array<BootstrapReport["tenants"][number]> = [];
275
+ for (const tenant of plan.tenants) {
276
+ const outcome = await ensureTenant(deps, tenant);
277
+ const shouldSeed = outcome === "created" && plan.seed !== undefined;
278
+ if (shouldSeed) {
279
+ await plan.seed?.({ tenantId: tenant.id, db: deps.db, dispatchSystemWrite });
280
+ }
281
+ const configApplied = await applyTenantConfig(deps, tenant);
282
+ tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
283
+ }
284
+
285
+ const invites: Array<BootstrapReport["invites"][number]> = [];
286
+ for (const invite of plannedInvites) {
287
+ const outcome = await decideInvite(deps, invite);
288
+ if (isMailingOutcome(outcome)) {
289
+ const result = await deps.dispatcher.write(
290
+ AuthHandlers.systemInviteCreate,
291
+ { email: invite.email, role: invite.role, globalRoles: invite.globalRoles },
292
+ createSystemUser(invite.tenantId),
293
+ );
294
+ if (!result.isSuccess) {
295
+ throw new BootstrapWriteError(
296
+ `inviting ${invite.email} into tenant ${invite.tenantId} failed: ${result.error.code} — ${result.error.message}`,
297
+ );
298
+ }
299
+ }
300
+ invites.push({
301
+ tenantId: invite.tenantId,
302
+ email: invite.email,
303
+ globalRoles: invite.globalRoles,
304
+ outcome,
305
+ });
306
+ }
307
+
308
+ return { tenants, invites };
309
+ }
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "breaking",
5
+ "title": "Invitations can carry a global role (SystemAdmin), granted only on accept",
6
+ "detail": "New system-only handler auth-email-password:write:system-invite-create and bootstrapTenants() for idempotent passwordless provisioning. Tenant admins cannot set global roles; a re-invite clears a pending one.",
7
+ "migration": "New column read_tenant_invitations.global_roles: run `kumiko schema generate` and apply the migration before deploying."
8
+ },
9
+ {
10
+ "version": "0.341.0",
11
+ "type": "improvement",
12
+ "title": "runBootstrap applies per-tenant config",
13
+ "detail": "BootstrapTenant.config sets tenant-scope config keys as system writes on every run; a value that already matches is not rewritten."
14
+ },
15
+ {
16
+ "version": "0.341.0",
17
+ "type": "fix",
18
+ "title": "System invite refuses to reset an accepted invitation (invite_already_accepted)"
19
+ },
2
20
  {
3
21
  "version": "0.339.0",
4
22
  "type": "improvement",
@@ -40,6 +40,9 @@ export const AuthHandlers = {
40
40
  // inviteSignupComplete: anon user with new email → POST token + password
41
41
  // inviteCancel: admin cancels a pending invite
42
42
  inviteCreate: "auth-email-password:write:invite-create",
43
+ // System-only variant (access.system) that may carry global roles — see
44
+ // invite-create.write.ts. Dispatched in-process by runBootstrap.
45
+ systemInviteCreate: "auth-email-password:write:system-invite-create",
43
46
  inviteAccept: "auth-email-password:write:invite-accept",
44
47
  inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
45
48
  inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
@@ -96,6 +99,8 @@ export const AuthErrors = {
96
99
  invalidInviteToken: "invalid_invite_token",
97
100
  inviteEmailMismatch: "invite_email_mismatch",
98
101
  inviteAlreadyMember: "invite_already_member",
102
+ // System invite (waitlist) refuses to reset an invitation the invitee already accepted.
103
+ inviteAlreadyAccepted: "invite_already_accepted",
99
104
  // Account-lockout: login refuses with this code when the user's streak of
100
105
  // failed attempts has crossed the configured threshold. The error detail
101
106
  // carries `retryAfterSeconds` so the UI can show a countdown. Returning a
@@ -28,6 +28,15 @@ export function inviteEmailMismatch() {
28
28
  );
29
29
  }
30
30
 
31
+ // @wrapper-known error-helper
32
+ export function inviteAlreadyAccepted() {
33
+ return writeFailure(
34
+ new UnprocessableError(AuthErrors.inviteAlreadyAccepted, {
35
+ i18nKey: "auth.errors.inviteAlreadyAccepted",
36
+ }),
37
+ );
38
+ }
39
+
31
40
  // @wrapper-known error-helper
32
41
  export function invalidResetToken() {
33
42
  return writeFailure(
@@ -8,6 +8,7 @@ import { createInviteAcceptHandler } from "./handlers/invite-accept.write.js";
8
8
  import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write.js";
9
9
  import {
10
10
  createInviteCreateHandler,
11
+ createSystemInviteCreateHandler,
11
12
  type InviteCreateOptions,
12
13
  } from "./handlers/invite-create.write.js";
13
14
  import { inviteInfoQuery } from "./handlers/invite-info.query.js";
@@ -246,6 +247,7 @@ export function createAuthEmailPasswordFeature(
246
247
 
247
248
  if (opts.invite) {
248
249
  r.writeHandler(createInviteCreateHandler(opts.invite));
250
+ r.writeHandler(createSystemInviteCreateHandler(opts.invite));
249
251
  r.writeHandler(createInviteAcceptHandler());
250
252
  // Same gates as the `login` handler above (lockout, password,
251
253
  // email-verified, account-status, MFA) — see invite-accept-with-login.write.ts.
@@ -42,6 +42,11 @@ import {
42
42
  } from "../../tenant/invitation-table.js";
43
43
  // kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add
44
44
  import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
45
+ // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
46
+ import {
47
+ grantInvitedGlobalRoles,
48
+ parseInvitedGlobalRoles,
49
+ } from "../../user/invited-global-roles.js";
45
50
  // kumiko-lint-ignore cross-feature-import login-style password-check
46
51
  import { userTable } from "../../user/schema/user.js";
47
52
  import {
@@ -143,6 +148,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
143
148
  readonly tenantId: TenantId;
144
149
  readonly email: string;
145
150
  readonly role: string;
151
+ readonly globalRoles: unknown;
146
152
  readonly version: number;
147
153
  readonly insertedAt: Temporal.Instant;
148
154
  readonly modifiedAt: Temporal.Instant | null;
@@ -222,6 +228,12 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
222
228
  });
223
229
  if (!grant.isSuccess) return grant;
224
230
 
231
+ const globalGrant = await grantInvitedGlobalRoles(dbConn, {
232
+ userId,
233
+ globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
234
+ });
235
+ if (!globalGrant.isSuccess) return globalGrant;
236
+
225
237
  // Invitation → accepted: TenantDb for the invitation's tenant.
226
238
  const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
227
239
  const updateResult = await invitationExecutor.update(
@@ -239,7 +251,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
239
251
 
240
252
  // buildSessionRoles calls stripForbiddenMembershipRoles internally —
241
253
  // a reserved role on an existing membership must never reach the session.
242
- const mergedRoles = buildSessionRoles([], grant.data.roles);
254
+ const mergedRoles = buildSessionRoles(globalGrant.data.roles, grant.data.roles);
243
255
 
244
256
  // MFA gate runs after membership is granted (mirrors login.write.ts's
245
257
  // own order: membership resolution before MFA) — a challenge halts
@@ -34,6 +34,11 @@ import {
34
34
  } from "../../tenant/invitation-table.js";
35
35
  // kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add (like provisionSignupAccount)
36
36
  import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
37
+ // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
38
+ import {
39
+ grantInvitedGlobalRoles,
40
+ parseInvitedGlobalRoles,
41
+ } from "../../user/invited-global-roles.js";
37
42
  // kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
38
43
  import { userTable } from "../../user/schema/user.js";
39
44
  import { invalidInviteToken, inviteEmailMismatch } from "../errors.js";
@@ -103,6 +108,7 @@ export function createInviteAcceptHandler() {
103
108
  readonly tenantId: TenantId;
104
109
  readonly email: string;
105
110
  readonly role: string;
111
+ readonly globalRoles: unknown;
106
112
  readonly version: number;
107
113
  readonly insertedAt: Temporal.Instant;
108
114
  readonly modifiedAt: Temporal.Instant | null;
@@ -153,6 +159,12 @@ export function createInviteAcceptHandler() {
153
159
  if (!grant.isSuccess) return grant;
154
160
  const { alreadyMember } = grant.data;
155
161
 
162
+ const globalGrant = await grantInvitedGlobalRoles(dbConn, {
163
+ userId: event.user.id,
164
+ globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
165
+ });
166
+ if (!globalGrant.isSuccess) return globalGrant;
167
+
156
168
  // Invitation-Status → accepted via event-store-executor.
157
169
  // Tenant-scoping: ctx.db ist auf event.user.tenantId gescopt
158
170
  // (= NICHT der invitation-tenant). Eigene TenantDb für den