@cosmicdrift/kumiko-bundled-features 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.
Files changed (166) hide show
  1. package/dist/auth-email-password/bootstrap.d.ts +74 -0
  2. package/dist/auth-email-password/bootstrap.js +165 -0
  3. package/dist/auth-email-password/constants.d.ts +2 -0
  4. package/dist/auth-email-password/constants.js +5 -0
  5. package/dist/auth-email-password/errors.d.ts +1 -0
  6. package/dist/auth-email-password/errors.js +6 -0
  7. package/dist/auth-email-password/feature.js +2 -1
  8. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  9. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  10. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  11. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  12. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  13. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  14. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  15. package/dist/auth-email-password/index.d.ts +2 -0
  16. package/dist/auth-email-password/index.js +1 -0
  17. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  18. package/dist/auth-email-password/invite-token-store.js +5 -0
  19. package/dist/auth-email-password/magic-link-mail.js +1 -0
  20. package/dist/auth-mfa/config.d.ts +1 -1
  21. package/dist/auth-mfa/config.js +8 -3
  22. package/dist/auth-mfa/feature.d.ts +2 -0
  23. package/dist/auth-mfa/feature.js +2 -1
  24. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  25. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  26. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  27. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  28. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  29. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  30. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  31. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  32. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  33. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  34. package/dist/billing-foundation/projection.js +23 -40
  35. package/dist/channel-discord/discord-channel.d.ts +4 -0
  36. package/dist/channel-discord/discord-channel.js +4 -0
  37. package/dist/channel-discord/feature.js +7 -2
  38. package/dist/channel-discord/index.d.ts +1 -1
  39. package/dist/channel-discord/index.js +1 -1
  40. package/dist/channel-email/email-channel.js +1 -0
  41. package/dist/channel-slack/feature.js +4 -2
  42. package/dist/channel-slack/index.d.ts +1 -1
  43. package/dist/channel-slack/index.js +1 -1
  44. package/dist/channel-slack/slack-channel.d.ts +4 -0
  45. package/dist/channel-slack/slack-channel.js +4 -0
  46. package/dist/channel-teams/feature.js +4 -2
  47. package/dist/channel-teams/index.d.ts +1 -1
  48. package/dist/channel-teams/index.js +1 -1
  49. package/dist/channel-teams/teams-channel.d.ts +4 -0
  50. package/dist/channel-teams/teams-channel.js +4 -0
  51. package/dist/channel-telegram/feature.js +4 -2
  52. package/dist/channel-telegram/index.d.ts +1 -1
  53. package/dist/channel-telegram/index.js +1 -1
  54. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  55. package/dist/channel-telegram/telegram-channel.js +28 -12
  56. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  57. package/dist/delivery/chat-webhook-channel.js +10 -5
  58. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  59. package/dist/delivery/chat-webhook-sender.js +13 -8
  60. package/dist/delivery/delivery-service.d.ts +4 -3
  61. package/dist/delivery/delivery-service.js +53 -33
  62. package/dist/delivery/index.d.ts +2 -2
  63. package/dist/delivery/index.js +2 -2
  64. package/dist/delivery/jobs.js +3 -1
  65. package/dist/delivery/types.d.ts +5 -3
  66. package/dist/inbound-mail-foundation/projection.js +37 -45
  67. package/dist/legal-pages/feature.d.ts +3 -15
  68. package/dist/legal-pages/feature.js +20 -50
  69. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  70. package/dist/legal-pages/lib/boot-check.js +33 -0
  71. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  72. package/dist/legal-pages/lib/validate-routes.js +17 -0
  73. package/dist/page-render/index.d.ts +2 -1
  74. package/dist/page-render/index.js +1 -0
  75. package/dist/page-render/layout.d.ts +7 -0
  76. package/dist/page-render/layout.js +5 -0
  77. package/dist/page-render/trailing-slash.d.ts +2 -0
  78. package/dist/page-render/trailing-slash.js +15 -0
  79. package/dist/renderer-simple/feature.js +1 -0
  80. package/dist/renderer-simple/index.d.ts +1 -1
  81. package/dist/renderer-simple/index.js +1 -1
  82. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  83. package/dist/renderer-simple/simple-renderer.js +66 -12
  84. package/dist/secrets/announce-secret-change.d.ts +8 -0
  85. package/dist/secrets/announce-secret-change.js +18 -0
  86. package/dist/secrets/feature.js +1 -1
  87. package/dist/secrets/handlers/delete.write.js +2 -0
  88. package/dist/secrets/handlers/set.write.js +6 -1
  89. package/dist/secrets/i18n.d.ts +4 -0
  90. package/dist/secrets/i18n.js +4 -0
  91. package/dist/secrets/secrets-context.d.ts +2 -0
  92. package/dist/secrets/secrets-context.js +3 -0
  93. package/dist/secrets/write-gate.d.ts +5 -1
  94. package/dist/secrets/write-gate.js +24 -1
  95. package/dist/tenant/constants.d.ts +1 -0
  96. package/dist/tenant/constants.js +1 -0
  97. package/dist/tenant/handlers/add-member.write.js +14 -3
  98. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  99. package/dist/tenant/handlers/invitations.query.js +15 -1
  100. package/dist/tenant/index.d.ts +1 -0
  101. package/dist/tenant/index.js +1 -0
  102. package/dist/tenant/invitation-table.d.ts +2 -0
  103. package/dist/tenant/invitation-table.js +10 -1
  104. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  105. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  106. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  107. package/dist/tier-engine/feature.js +83 -14
  108. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  109. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  110. package/dist/user/handlers/update-roles.js +7 -18
  111. package/dist/user/invited-global-roles.d.ts +14 -0
  112. package/dist/user/invited-global-roles.js +35 -0
  113. package/dist/user-data-rights/feature.js +3 -0
  114. package/dist/waitlist/constants.d.ts +33 -0
  115. package/dist/waitlist/constants.js +35 -0
  116. package/dist/waitlist/entity.d.ts +433 -0
  117. package/dist/waitlist/entity.js +64 -0
  118. package/dist/waitlist/feature.d.ts +3 -0
  119. package/dist/waitlist/feature.js +38 -0
  120. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  121. package/dist/waitlist/handlers/invite.write.js +119 -0
  122. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  123. package/dist/waitlist/handlers/list.query.js +10 -0
  124. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  125. package/dist/waitlist/handlers/reject.write.js +80 -0
  126. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  127. package/dist/waitlist/handlers/submit.write.js +112 -0
  128. package/dist/waitlist/i18n.d.ts +6 -0
  129. package/dist/waitlist/i18n.js +31 -0
  130. package/dist/waitlist/index.d.ts +5 -0
  131. package/dist/waitlist/index.js +4 -0
  132. package/dist/waitlist/lib.d.ts +10 -0
  133. package/dist/waitlist/lib.js +24 -0
  134. package/dist/waitlist/mail.d.ts +15 -0
  135. package/dist/waitlist/mail.js +73 -0
  136. package/dist/waitlist/options.d.ts +29 -0
  137. package/dist/waitlist/options.js +8 -0
  138. package/dist/waitlist/payloads.d.ts +14 -0
  139. package/dist/waitlist/payloads.js +47 -0
  140. package/dist/waitlist/screens.d.ts +2 -0
  141. package/dist/waitlist/screens.js +42 -0
  142. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  143. package/dist/waitlist/user-data-hooks.js +71 -0
  144. package/dist/workflow-runner/db/queries/due-runs.d.ts +4 -5
  145. package/dist/workflow-runner/db/queries/due-runs.js +5 -4
  146. package/dist/workflow-runner/feature.js +4 -6
  147. package/package.json +17 -9
  148. package/src/auth-email-password/changes.json +18 -0
  149. package/src/auth-mfa/changes.json +6 -0
  150. package/src/billing-foundation/changes.json +13 -0
  151. package/src/channel-telegram/changes.json +9 -0
  152. package/src/delivery/changes.json +7 -0
  153. package/src/files/changes.json +13 -1
  154. package/src/inbound-mail-foundation/changes.json +7 -0
  155. package/src/renderer-simple/changes.json +6 -0
  156. package/src/secrets/changes.json +12 -0
  157. package/src/tenant/changes.json +6 -0
  158. package/src/user/changes.json +13 -6
  159. package/src/waitlist/changes.json +17 -0
  160. package/src/workflow-runner/changes.json +7 -0
  161. package/dist/billing-foundation/db/queries/payment-projection.d.ts +0 -2
  162. package/dist/billing-foundation/db/queries/payment-projection.js +0 -13
  163. package/dist/billing-foundation/db/queries/subscription-projection.d.ts +0 -2
  164. package/dist/billing-foundation/db/queries/subscription-projection.js +0 -7
  165. package/dist/inbound-mail-foundation/db/queries/inbound-projections.d.ts +0 -7
  166. package/dist/inbound-mail-foundation/db/queries/inbound-projections.js +0 -24
@@ -0,0 +1,74 @@
1
+ import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
5
+ import type { Redis } from "ioredis";
6
+ import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
7
+ type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
8
+ export type BootstrapInvite = {
9
+ readonly email: string;
10
+ /** Membership role in the tenant, validated like a tenant-admin invite
11
+ * (reserved roles such as SystemAdmin are rejected here). */
12
+ readonly role: string;
13
+ };
14
+ export type BootstrapSeedDeps = {
15
+ readonly tenantId: TenantId;
16
+ readonly db: DbConnection;
17
+ readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
18
+ };
19
+ export type BootstrapConfigValue = string | number | boolean;
20
+ export type BootstrapTenant = {
21
+ readonly id: TenantId;
22
+ readonly key: string;
23
+ readonly name: string;
24
+ /** Tenant-scope config values by qualified key (e.g.
25
+ * `"auth-mfa:config:required": "admins"`), written as system writes on
26
+ * EVERY run: config is declarative, so a rerun converges to the plan and
27
+ * a value that already matches is not rewritten. Unlike `seed` it also
28
+ * applies to tenants that already exist. */
29
+ readonly config?: Readonly<Record<string, BootstrapConfigValue>>;
30
+ readonly invites?: readonly BootstrapInvite[];
31
+ };
32
+ export type BootstrapSystemAdmin = {
33
+ readonly email: string;
34
+ /** Home tenant: login needs a membership, so the SystemAdmin is invited
35
+ * into this tenant with `role` and receives the global role on accept. */
36
+ readonly tenantId: TenantId;
37
+ readonly role: string;
38
+ };
39
+ export type BootstrapPlan = {
40
+ readonly tenants: readonly BootstrapTenant[];
41
+ readonly systemAdmins?: readonly BootstrapSystemAdmin[];
42
+ /** Runs for each tenant right after this run created it. An existing
43
+ * tenant is never seeded again — also not when a previous run failed
44
+ * inside the seed (that run fails; clean up by hand). */
45
+ readonly seed?: (deps: BootstrapSeedDeps) => Promise<void>;
46
+ };
47
+ export type BootstrapDeps = {
48
+ readonly db: DbConnection;
49
+ readonly redis: Redis;
50
+ readonly dispatcher: Dispatcher;
51
+ };
52
+ export type BootstrapTenantOutcome = "created" | "exists";
53
+ export type BootstrapInviteOutcome = "invited" | "resent" | "pending" | "active" | "cancelled" | "role-mismatch";
54
+ export type BootstrapReport = {
55
+ readonly tenants: ReadonlyArray<{
56
+ readonly id: TenantId;
57
+ readonly outcome: BootstrapTenantOutcome;
58
+ readonly seeded: boolean;
59
+ /** Qualified keys this run wrote (new or changed); empty on a no-op rerun. */
60
+ readonly configApplied: readonly string[];
61
+ }>;
62
+ readonly invites: ReadonlyArray<{
63
+ readonly tenantId: TenantId;
64
+ readonly email: string;
65
+ readonly globalRoles: readonly GlobalRole[];
66
+ readonly outcome: BootstrapInviteOutcome;
67
+ }>;
68
+ };
69
+ export declare class BootstrapPlanError extends Error {
70
+ }
71
+ export declare class BootstrapWriteError extends Error {
72
+ }
73
+ export declare function bootstrapTenants(deps: BootstrapDeps, plan: BootstrapPlan): Promise<BootstrapReport>;
74
+ export {};
@@ -0,0 +1,165 @@
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
+ import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
8
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { ConfigScopes, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
11
+ // kumiko-lint-ignore cross-feature-import bootstrap sets per-tenant config through the config feature's write handler
12
+ import { ConfigHandlers } from "../config/constants.js";
13
+ // kumiko-lint-ignore cross-feature-import bootstrap compares against the stored config row to stay idempotent
14
+ import { findConfigRow } from "../config/write-helpers.js";
15
+ // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
16
+ import { TenantHandlers } from "../tenant/constants.js";
17
+ // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
18
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../tenant/invitation-table.js";
19
+ // kumiko-lint-ignore cross-feature-import bootstrap checks existing memberships
20
+ import { tenantMembershipsTable } from "../tenant/membership-table.js";
21
+ // kumiko-lint-ignore cross-feature-import bootstrap checks for an existing tenant
22
+ import { tenantTable } from "../tenant/schema/tenant.js";
23
+ // kumiko-lint-ignore cross-feature-import bootstrap skips already activated users
24
+ import { userTable } from "../user/schema/user.js";
25
+ import { AuthHandlers } from "./constants.js";
26
+ import { hasLiveInviteToken } from "./invite-token-store.js";
27
+ export class BootstrapPlanError extends Error {
28
+ }
29
+ export class BootstrapWriteError extends Error {
30
+ }
31
+ function planInvites(plan) {
32
+ const tenantIds = new Set(plan.tenants.map((t) => t.id));
33
+ const invites = [];
34
+ for (const admin of plan.systemAdmins ?? []) {
35
+ if (!tenantIds.has(admin.tenantId)) {
36
+ throw new BootstrapPlanError(`systemAdmins: ${admin.email} references tenant ${admin.tenantId}, which is not in tenants`);
37
+ }
38
+ invites.push({
39
+ tenantId: admin.tenantId,
40
+ email: admin.email.toLowerCase(),
41
+ role: admin.role,
42
+ globalRoles: ["SystemAdmin"],
43
+ });
44
+ }
45
+ for (const tenant of plan.tenants) {
46
+ for (const invite of tenant.invites ?? []) {
47
+ invites.push({
48
+ tenantId: tenant.id,
49
+ email: invite.email.toLowerCase(),
50
+ role: invite.role,
51
+ globalRoles: [],
52
+ });
53
+ }
54
+ }
55
+ // One invitation row per (tenant, email): a second entry would overwrite
56
+ // the first one's role or global roles.
57
+ const seen = new Set();
58
+ for (const invite of invites) {
59
+ const key = `${invite.tenantId}\u0000${invite.email}`;
60
+ if (seen.has(key)) {
61
+ throw new BootstrapPlanError(`${invite.email} is listed twice for tenant ${invite.tenantId} (systemAdmins and tenants[].invites share one invitation per tenant)`);
62
+ }
63
+ seen.add(key);
64
+ }
65
+ return invites;
66
+ }
67
+ async function ensureTenant(deps, tenant) {
68
+ const existing = await fetchOne(deps.db, tenantTable, { id: tenant.id });
69
+ if (existing)
70
+ return "exists";
71
+ const result = await deps.dispatcher.write(TenantHandlers.create, { id: tenant.id, key: tenant.key, name: tenant.name }, createSystemUser(tenant.id));
72
+ if (!result.isSuccess) {
73
+ throw new BootstrapWriteError(`creating tenant ${tenant.key} (${tenant.id}) failed: ${result.error.code} — ${result.error.message}`);
74
+ }
75
+ return "created";
76
+ }
77
+ async function applyTenantConfig(deps, tenant) {
78
+ const applied = [];
79
+ for (const [key, value] of Object.entries(tenant.config ?? {})) {
80
+ const existing = await findConfigRow(deps.db, key, tenant.id, null);
81
+ if (existing?.value === JSON.stringify(value))
82
+ continue;
83
+ const result = await deps.dispatcher.write(ConfigHandlers.set, { key, value, scope: ConfigScopes.tenant },
84
+ // SystemAdmin on top of the system role: keys with the default tenant
85
+ // write access (admin roles) reject a bare system user, system-only keys
86
+ // accept it.
87
+ createSystemUser(tenant.id, ["SystemAdmin"]));
88
+ if (!result.isSuccess) {
89
+ throw new BootstrapWriteError(`setting config ${key} for tenant ${tenant.id} failed: ${result.error.code} — ${result.error.message}`);
90
+ }
91
+ applied.push(key);
92
+ }
93
+ return applied;
94
+ }
95
+ async function holdsInvitedAccess(deps, invite) {
96
+ const user = await fetchOne(deps.db, userTable, {
97
+ email: invite.email,
98
+ isDeleted: false,
99
+ });
100
+ if (!user)
101
+ return false;
102
+ const membership = await fetchOne(deps.db, tenantMembershipsTable, {
103
+ userId: user.id,
104
+ tenantId: invite.tenantId,
105
+ });
106
+ if (!membership)
107
+ return false;
108
+ const userGlobalRoles = parseRoles(user.roles);
109
+ return invite.globalRoles.every((role) => userGlobalRoles.includes(role));
110
+ }
111
+ async function decideInvite(deps, invite) {
112
+ if (await holdsInvitedAccess(deps, invite))
113
+ return "active";
114
+ const invitation = await fetchOne(deps.db, tenantInvitationsTable, {
115
+ tenantId: invite.tenantId,
116
+ email: invite.email,
117
+ });
118
+ if (!invitation)
119
+ return "invited";
120
+ if (invitation.status === INVITATION_STATUS.cancelled)
121
+ return "cancelled";
122
+ const storedGlobalRoles = parseRoles(invitation.globalRoles);
123
+ if (!invite.globalRoles.every((role) => storedGlobalRoles.includes(role))) {
124
+ return "role-mismatch";
125
+ }
126
+ if (invitation.status === INVITATION_STATUS.accepted)
127
+ return "active";
128
+ if (await hasLiveInviteToken(deps.redis, invitation.id))
129
+ return "pending";
130
+ return "resent";
131
+ }
132
+ function isMailingOutcome(outcome) {
133
+ return outcome === "invited" || outcome === "resent";
134
+ }
135
+ export async function bootstrapTenants(deps, plan) {
136
+ const plannedInvites = planInvites(plan);
137
+ const dispatchSystemWrite = makeDispatchSystemWrite(deps.dispatcher);
138
+ const tenants = [];
139
+ for (const tenant of plan.tenants) {
140
+ const outcome = await ensureTenant(deps, tenant);
141
+ const shouldSeed = outcome === "created" && plan.seed !== undefined;
142
+ if (shouldSeed) {
143
+ await plan.seed?.({ tenantId: tenant.id, db: deps.db, dispatchSystemWrite });
144
+ }
145
+ const configApplied = await applyTenantConfig(deps, tenant);
146
+ tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
147
+ }
148
+ const invites = [];
149
+ for (const invite of plannedInvites) {
150
+ const outcome = await decideInvite(deps, invite);
151
+ if (isMailingOutcome(outcome)) {
152
+ const result = await deps.dispatcher.write(AuthHandlers.systemInviteCreate, { email: invite.email, role: invite.role, globalRoles: invite.globalRoles }, createSystemUser(invite.tenantId));
153
+ if (!result.isSuccess) {
154
+ throw new BootstrapWriteError(`inviting ${invite.email} into tenant ${invite.tenantId} failed: ${result.error.code} — ${result.error.message}`);
155
+ }
156
+ }
157
+ invites.push({
158
+ tenantId: invite.tenantId,
159
+ email: invite.email,
160
+ globalRoles: invite.globalRoles,
161
+ outcome,
162
+ });
163
+ }
164
+ return { tenants, invites };
165
+ }
@@ -13,6 +13,7 @@ export declare const AuthHandlers: {
13
13
  readonly signupRequest: "auth-email-password:write:signup-request";
14
14
  readonly signupConfirm: "auth-email-password:write:signup-confirm";
15
15
  readonly inviteCreate: "auth-email-password:write:invite-create";
16
+ readonly systemInviteCreate: "auth-email-password:write:system-invite-create";
16
17
  readonly inviteAccept: "auth-email-password:write:invite-accept";
17
18
  readonly inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login";
18
19
  readonly inviteSignupComplete: "auth-email-password:write:invite-signup-complete";
@@ -38,6 +39,7 @@ export declare const AuthErrors: {
38
39
  readonly invalidInviteToken: "invalid_invite_token";
39
40
  readonly inviteEmailMismatch: "invite_email_mismatch";
40
41
  readonly inviteAlreadyMember: "invite_already_member";
42
+ readonly inviteAlreadyAccepted: "invite_already_accepted";
41
43
  readonly accountLocked: "account_locked";
42
44
  readonly accountRestricted: "account_restricted";
43
45
  };
@@ -38,6 +38,9 @@ export const AuthHandlers = {
38
38
  // inviteSignupComplete: anon user with new email → POST token + password
39
39
  // inviteCancel: admin cancels a pending invite
40
40
  inviteCreate: "auth-email-password:write:invite-create",
41
+ // System-only variant (access.system) that may carry global roles — see
42
+ // invite-create.write.ts. Dispatched in-process by runBootstrap.
43
+ systemInviteCreate: "auth-email-password:write:system-invite-create",
41
44
  inviteAccept: "auth-email-password:write:invite-accept",
42
45
  inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
43
46
  inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
@@ -92,6 +95,8 @@ export const AuthErrors = {
92
95
  invalidInviteToken: "invalid_invite_token",
93
96
  inviteEmailMismatch: "invite_email_mismatch",
94
97
  inviteAlreadyMember: "invite_already_member",
98
+ // System invite (waitlist) refuses to reset an invitation the invitee already accepted.
99
+ inviteAlreadyAccepted: "invite_already_accepted",
95
100
  // Account-lockout: login refuses with this code when the user's streak of
96
101
  // failed attempts has crossed the configured threshold. The error detail
97
102
  // carries `retryAfterSeconds` so the UI can show a countdown. Returning a
@@ -1,6 +1,7 @@
1
1
  export declare function invalidCredentials(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
2
2
  export declare function invalidInviteToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
3
3
  export declare function inviteEmailMismatch(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
4
+ export declare function inviteAlreadyAccepted(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
4
5
  export declare function invalidResetToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
5
6
  export declare function invalidUnlockToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
6
7
  export declare function invalidVerificationToken(): import("@cosmicdrift/kumiko-types/write-error-info-types").WriteFailure;
@@ -19,6 +19,12 @@ export function inviteEmailMismatch() {
19
19
  }));
20
20
  }
21
21
  // @wrapper-known error-helper
22
+ export function inviteAlreadyAccepted() {
23
+ return writeFailure(new UnprocessableError(AuthErrors.inviteAlreadyAccepted, {
24
+ i18nKey: "auth.errors.inviteAlreadyAccepted",
25
+ }));
26
+ }
27
+ // @wrapper-known error-helper
22
28
  export function invalidResetToken() {
23
29
  return writeFailure(new UnprocessableError(AuthErrors.invalidResetToken, {
24
30
  i18nKey: "auth.errors.invalidResetToken",
@@ -5,7 +5,7 @@ import { changePasswordWrite } from "./handlers/change-password.write.js";
5
5
  import { createConfirmAccountUnlockHandler } from "./handlers/confirm-account-unlock.write.js";
6
6
  import { createInviteAcceptHandler } from "./handlers/invite-accept.write.js";
7
7
  import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write.js";
8
- import { createInviteCreateHandler, } from "./handlers/invite-create.write.js";
8
+ import { createInviteCreateHandler, createSystemInviteCreateHandler, } from "./handlers/invite-create.write.js";
9
9
  import { inviteInfoQuery } from "./handlers/invite-info.query.js";
10
10
  import { createInviteSignupCompleteHandler } from "./handlers/invite-signup-complete.write.js";
11
11
  import { createLoginHandler } from "./handlers/login.write.js";
@@ -123,6 +123,7 @@ export function createAuthEmailPasswordFeature(opts = {}) {
123
123
  }
124
124
  if (opts.invite) {
125
125
  r.writeHandler(createInviteCreateHandler(opts.invite));
126
+ r.writeHandler(createSystemInviteCreateHandler(opts.invite));
126
127
  r.writeHandler(createInviteAcceptHandler());
127
128
  // Same gates as the `login` handler above (lockout, password,
128
129
  // email-verified, account-status, MFA) — see invite-accept-with-login.write.ts.
@@ -30,6 +30,8 @@ import { decryptStoredPii, sessionLocaleField, sessionTimezoneField } from "../.
30
30
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
31
31
  // kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add
32
32
  import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
33
+ // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
34
+ import { grantInvitedGlobalRoles, parseInvitedGlobalRoles, } from "../../user/invited-global-roles.js";
33
35
  // kumiko-lint-ignore cross-feature-import login-style password-check
34
36
  import { userTable } from "../../user/schema/user.js";
35
37
  import { AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES, AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS, } from "../constants.js";
@@ -120,6 +122,12 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
120
122
  });
121
123
  if (!grant.isSuccess)
122
124
  return grant;
125
+ const globalGrant = await grantInvitedGlobalRoles(dbConn, {
126
+ userId,
127
+ globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
128
+ });
129
+ if (!globalGrant.isSuccess)
130
+ return globalGrant;
123
131
  // Invitation → accepted: TenantDb for the invitation's tenant.
124
132
  const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
125
133
  const updateResult = await invitationExecutor.update({
@@ -132,7 +140,7 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
132
140
  await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
133
141
  // buildSessionRoles calls stripForbiddenMembershipRoles internally —
134
142
  // a reserved role on an existing membership must never reach the session.
135
- const mergedRoles = buildSessionRoles([], grant.data.roles);
143
+ const mergedRoles = buildSessionRoles(globalGrant.data.roles, grant.data.roles);
136
144
  // MFA gate runs after membership is granted (mirrors login.write.ts's
137
145
  // own order: membership resolution before MFA) — a challenge halts
138
146
  // the session mint, not the invite acceptance itself.
@@ -24,6 +24,8 @@ import { decryptStoredPii } from "../../shared/index.js";
24
24
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
25
25
  // kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add (like provisionSignupAccount)
26
26
  import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
27
+ // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
28
+ import { grantInvitedGlobalRoles, parseInvitedGlobalRoles, } from "../../user/invited-global-roles.js";
27
29
  // kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
28
30
  import { userTable } from "../../user/schema/user.js";
29
31
  import { invalidInviteToken, inviteEmailMismatch } from "../errors.js";
@@ -96,6 +98,12 @@ export function createInviteAcceptHandler() {
96
98
  if (!grant.isSuccess)
97
99
  return grant;
98
100
  const { alreadyMember } = grant.data;
101
+ const globalGrant = await grantInvitedGlobalRoles(dbConn, {
102
+ userId: event.user.id,
103
+ globalRoles: parseInvitedGlobalRoles(invitation.globalRoles),
104
+ });
105
+ if (!globalGrant.isSuccess)
106
+ return globalGrant;
99
107
  // Invitation-Status → accepted via event-store-executor.
100
108
  // Tenant-scoping: ctx.db ist auf event.user.tenantId gescopt
101
109
  // (= NICHT der invitation-tenant). Eigene TenantDb für den
@@ -1,11 +1,14 @@
1
1
  import * as z from "zod";
2
+ import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
2
3
  import type { AuthMailLocale } from "../email-templates.js";
4
+ type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
3
5
  export type InviteCreateData = {
4
6
  readonly kind: "invite-created";
5
7
  readonly invitationId: string;
6
8
  readonly tenantId: string;
7
9
  readonly email: string;
8
10
  readonly role: string;
11
+ readonly globalRoles: readonly GlobalRole[];
9
12
  readonly expiresAt: string;
10
13
  };
11
14
  export type InviteCreateOptions = {
@@ -31,3 +34,11 @@ export declare function createInviteCreateHandler(opts: InviteCreateOptions): im
31
34
  email: z.ZodEmail;
32
35
  role: z.ZodString;
33
36
  }, z.core.$strip>, InviteCreateData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
37
+ export declare function createSystemInviteCreateHandler(opts: InviteCreateOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"system-invite-create", z.ZodObject<{
38
+ email: z.ZodEmail;
39
+ role: z.ZodString;
40
+ globalRoles: z.ZodDefault<z.ZodArray<z.ZodEnum<{
41
+ SystemAdmin: "SystemAdmin";
42
+ }>>>;
43
+ }, z.core.$strip>, InviteCreateData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
44
+ export {};
@@ -19,15 +19,18 @@
19
19
  import { generateToken } from "@cosmicdrift/kumiko-framework/api";
20
20
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
21
21
  import { access, assignableAppRolesOf, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
22
- import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
22
+ import { InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
23
23
  import { Temporal } from "temporal-polyfill";
24
24
  import * as z from "zod";
25
25
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
26
26
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
27
27
  // kumiko-lint-ignore cross-feature-import membership-role validation owned by tenant-feature
28
28
  import { findForbiddenMembershipRole, findForbiddenRoleAssignment, reservedMembershipRoleError, unassignableMembershipRoleError, } from "../../tenant/membership-roles.js";
29
+ // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
30
+ import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
29
31
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
30
32
  import { renderInviteEmail } from "../email-templates.js";
33
+ import { inviteAlreadyAccepted } from "../errors.js";
31
34
  import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store.js";
32
35
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
33
36
  const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
@@ -44,102 +47,146 @@ const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitati
44
47
  function findRankedRole(roles) {
45
48
  return roles.find((role) => findForbiddenRoleAssignment(["system"], [role], []) === undefined);
46
49
  }
47
- export function createInviteCreateHandler(opts) {
50
+ function checkAssignableInviteRole(opts, inviterRoles, role, ctx) {
51
+ const forbiddenRole = findForbiddenMembershipRole([role]);
52
+ if (forbiddenRole !== undefined)
53
+ return writeFailure(reservedMembershipRoleError(forbiddenRole));
54
+ const elevationForbidden = findForbiddenRoleAssignment(inviterRoles, [role], [], assignableAppRolesOf(ctx.registry));
55
+ if (elevationForbidden !== undefined &&
56
+ !opts.additionalAssignableRoles?.includes(elevationForbidden)) {
57
+ return writeFailure(unassignableMembershipRoleError(elevationForbidden));
58
+ }
59
+ if (opts.canAssignRole && !opts.canAssignRole(inviterRoles, role)) {
60
+ return writeFailure(unassignableMembershipRoleError(role));
61
+ }
62
+ return undefined;
63
+ }
64
+ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAccepted = false) {
65
+ if (!ctx.redis) {
66
+ return writeFailure(new InternalError({ message: "invite-create requires ctx.redis for token store" }));
67
+ }
68
+ const email = request.email.toLowerCase();
69
+ const tenantId = inviter.tenantId;
70
+ const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
71
+ // The unique index allows one row per (tenantId, email). Whatever its
72
+ // status, a re-invite resets it to pending with a fresh token.
73
+ const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
74
+ let invitationId;
75
+ if (existing) {
76
+ // Provisioning callers (waitlist) must not flip an accepted invite back to
77
+ // pending: that would let them cancel a live membership's invitation.
78
+ // The tenant-admin path keeps resetting because re-inviting a member is
79
+ // how roles get re-assigned.
80
+ if (refuseAccepted && existing["status"] === INVITATION_STATUS.accepted) {
81
+ return inviteAlreadyAccepted();
82
+ }
83
+ invitationId = existing["id"]; // @cast-boundary db-row
84
+ const existingVersion = existing["version"]; // @cast-boundary db-row
85
+ // At most one live invite token per invitation: invalidate
86
+ // whatever's there before minting the new one.
87
+ await invalidateExistingInviteToken(ctx.redis, invitationId);
88
+ const updateResult = await executor.update({
89
+ id: invitationId,
90
+ version: existingVersion,
91
+ changes: {
92
+ role: request.role,
93
+ // Always written, also as []: a tenant-admin resend must drop
94
+ // global roles a previous system invite put on this row.
95
+ globalRoles: [...request.globalRoles],
96
+ status: INVITATION_STATUS.pending,
97
+ invitedBy: inviter.id,
98
+ expiresAt,
99
+ },
100
+ }, inviter, ctx.db);
101
+ if (!updateResult.isSuccess)
102
+ return updateResult;
103
+ }
104
+ else {
105
+ const createResult = await executor.create({
106
+ email,
107
+ role: request.role,
108
+ globalRoles: [...request.globalRoles],
109
+ status: INVITATION_STATUS.pending,
110
+ invitedBy: inviter.id,
111
+ expiresAt,
112
+ }, inviter, ctx.db);
113
+ if (!createResult.isSuccess)
114
+ return createResult;
115
+ invitationId = createResult.data.id; // @cast-boundary engine-payload
116
+ }
117
+ const token = generateToken();
118
+ await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
119
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
120
+ await dispatchMagicLinkMail(ctx.notify, {
121
+ handlerName: "invite-create",
122
+ notificationType: INVITE_NOTIFICATION_TYPE,
123
+ renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: request.role }),
124
+ }, {
125
+ email,
126
+ appUrl: opts.appUrl,
127
+ token,
128
+ expiresAt: expiresAt.toString(),
129
+ ...(opts.appName !== undefined && { appName: opts.appName }),
130
+ locale,
131
+ });
132
+ return {
133
+ isSuccess: true,
134
+ data: {
135
+ kind: "invite-created",
136
+ invitationId,
137
+ tenantId,
138
+ email,
139
+ role: request.role,
140
+ globalRoles: request.globalRoles,
141
+ expiresAt: expiresAt.toString(),
142
+ },
143
+ };
144
+ }
145
+ function assertAppDefinedAdditionalRoles(opts) {
48
146
  const rankedRole = findRankedRole(opts.additionalAssignableRoles ?? []);
49
147
  if (rankedRole !== undefined) {
50
148
  throw new Error(`[auth-email-password] invite.additionalAssignableRoles must list app-defined roles only; "${rankedRole}" is a framework-ranked role`);
51
149
  }
52
- const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
53
- const ttlSeconds = ttlMinutes * 60;
150
+ }
151
+ export function createInviteCreateHandler(opts) {
152
+ assertAppDefinedAdditionalRoles(opts);
153
+ const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
54
154
  return defineWriteHandler({
55
155
  name: "invite-create",
56
156
  schema: InviteCreateSchema,
57
157
  access: { roles: access.admin },
58
158
  description: "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.",
59
159
  handler: async (event, ctx) => {
60
- if (!ctx.redis) {
61
- return writeFailure(new InternalError({ message: "invite-create requires ctx.redis for token store" }));
62
- }
63
- const forbiddenRole = findForbiddenMembershipRole([event.payload.role]);
64
- if (forbiddenRole !== undefined) {
65
- return writeFailure(reservedMembershipRoleError(forbiddenRole));
66
- }
67
- const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [event.payload.role], [], assignableAppRolesOf(ctx.registry));
68
- if (elevationForbidden !== undefined &&
69
- !opts.additionalAssignableRoles?.includes(elevationForbidden)) {
70
- return writeFailure(unassignableMembershipRoleError(elevationForbidden));
71
- }
72
- if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
73
- return writeFailure(unassignableMembershipRoleError(event.payload.role));
74
- }
75
- const email = event.payload.email.toLowerCase();
76
- const tenantId = event.user.tenantId;
77
- const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
78
- // Existing row für (tenantId, email) — unique-index garantiert
79
- // max. eine Row. Status egal (cancelled/accepted/expired/pending);
80
- // wir setzen sie auf pending zurück und vergeben einen frischen
81
- // Token wenn der bisherige nicht mehr lebt.
82
- const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
83
- let invitationId;
84
- let token;
85
- if (existing) {
86
- invitationId = existing["id"]; // @cast-boundary db-row
87
- const existingVersion = existing["version"]; // @cast-boundary db-row
88
- // At most one live invite token per invitation: invalidate
89
- // whatever's there before minting the new one.
90
- await invalidateExistingInviteToken(ctx.redis, invitationId);
91
- token = generateToken();
92
- const updateResult = await executor.update({
93
- id: invitationId,
94
- version: existingVersion,
95
- changes: {
96
- role: event.payload.role,
97
- status: INVITATION_STATUS.pending,
98
- invitedBy: event.user.id,
99
- expiresAt,
100
- },
101
- }, event.user, ctx.db);
102
- if (!updateResult.isSuccess)
103
- return updateResult;
104
- }
105
- else {
106
- const createResult = await executor.create({
107
- email,
108
- role: event.payload.role,
109
- status: INVITATION_STATUS.pending,
110
- invitedBy: event.user.id,
111
- expiresAt,
112
- }, event.user, ctx.db);
113
- if (!createResult.isSuccess)
114
- return createResult;
115
- invitationId = createResult.data.id; // @cast-boundary engine-payload
116
- token = generateToken();
117
- }
118
- await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
119
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
120
- await dispatchMagicLinkMail(ctx.notify, {
121
- handlerName: "invite-create",
122
- notificationType: INVITE_NOTIFICATION_TYPE,
123
- renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: event.payload.role }),
124
- }, {
125
- email,
126
- appUrl: opts.appUrl,
127
- token,
128
- expiresAt: expiresAt.toString(),
129
- ...(opts.appName !== undefined && { appName: opts.appName }),
130
- locale,
131
- });
132
- return {
133
- isSuccess: true,
134
- data: {
135
- kind: "invite-created",
136
- invitationId,
137
- tenantId,
138
- email,
139
- role: event.payload.role,
140
- expiresAt: expiresAt.toString(),
141
- },
142
- };
160
+ const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
161
+ if (roleRejection)
162
+ return roleRejection;
163
+ return issueInvitation(opts, ttlSeconds, { email: event.payload.email, role: event.payload.role, globalRoles: [] }, event.user, ctx);
164
+ },
165
+ });
166
+ }
167
+ const SystemInviteCreateSchema = z.object({
168
+ email: z.email(),
169
+ role: z.string().min(1).max(50),
170
+ globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
171
+ });
172
+ // System-only sibling of invite-create: the only path that can put global
173
+ // roles (SystemAdmin) on an invitation. No session mint path yields the
174
+ // "system" role (users.roles only accepts GLOBAL_ROLE_OPTIONS), so only
175
+ // in-process callers dispatching as createSystemUser (runBootstrap) get here.
176
+ export function createSystemInviteCreateHandler(opts) {
177
+ assertAppDefinedAdditionalRoles(opts);
178
+ const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
179
+ return defineWriteHandler({
180
+ name: "system-invite-create",
181
+ schema: SystemInviteCreateSchema,
182
+ access: { roles: access.system },
183
+ agent: { expose: false },
184
+ description: "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.",
185
+ handler: async (event, ctx) => {
186
+ const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
187
+ if (roleRejection)
188
+ return roleRejection;
189
+ return issueInvitation(opts, ttlSeconds, event.payload, event.user, ctx, true);
143
190
  },
144
191
  });
145
192
  }