@cosmicdrift/kumiko-bundled-features 0.340.0 → 0.342.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 (174) hide show
  1. package/dist/audit/constants.d.ts +3 -0
  2. package/dist/audit/constants.js +3 -0
  3. package/dist/audit/feature.js +34 -7
  4. package/dist/audit/run-escape-hatch-retention.d.ts +10 -0
  5. package/dist/audit/run-escape-hatch-retention.js +27 -0
  6. package/dist/auth-email-password/bootstrap.d.ts +80 -0
  7. package/dist/auth-email-password/bootstrap.js +170 -0
  8. package/dist/auth-email-password/constants.d.ts +2 -0
  9. package/dist/auth-email-password/constants.js +5 -0
  10. package/dist/auth-email-password/errors.d.ts +1 -0
  11. package/dist/auth-email-password/errors.js +6 -0
  12. package/dist/auth-email-password/feature.js +2 -1
  13. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  14. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  15. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  16. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  17. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  18. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  19. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +1 -0
  22. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  23. package/dist/auth-email-password/invite-token-store.js +5 -0
  24. package/dist/auth-email-password/magic-link-mail.js +1 -0
  25. package/dist/auth-email-password/web/auth-form-primitives.d.ts +7 -1
  26. package/dist/auth-email-password/web/auth-form-primitives.js +5 -5
  27. package/dist/auth-email-password/web/confirm-account-unlock-screen.js +3 -3
  28. package/dist/auth-email-password/web/forgot-password-screen.js +2 -2
  29. package/dist/auth-email-password/web/invite-accept-screen.js +9 -9
  30. package/dist/auth-email-password/web/login-screen.js +8 -8
  31. package/dist/auth-email-password/web/request-account-unlock-screen.js +2 -2
  32. package/dist/auth-email-password/web/reset-password-screen.js +3 -3
  33. package/dist/auth-email-password/web/session-bootstrap-error.js +3 -3
  34. package/dist/auth-email-password/web/signup-complete-screen.js +3 -3
  35. package/dist/auth-email-password/web/signup-screen.js +2 -2
  36. package/dist/auth-email-password/web/verify-email-screen.js +3 -3
  37. package/dist/auth-mfa/config.d.ts +1 -1
  38. package/dist/auth-mfa/config.js +8 -3
  39. package/dist/auth-mfa/feature.d.ts +2 -0
  40. package/dist/auth-mfa/feature.js +2 -1
  41. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  42. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  43. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +5 -5
  44. package/dist/auth-mfa/web/mfa-verify-screen.js +3 -3
  45. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  46. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  47. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  48. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  49. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  50. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  51. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  52. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  53. package/dist/channel-discord/discord-channel.d.ts +4 -0
  54. package/dist/channel-discord/discord-channel.js +4 -0
  55. package/dist/channel-discord/feature.js +7 -2
  56. package/dist/channel-discord/index.d.ts +1 -1
  57. package/dist/channel-discord/index.js +1 -1
  58. package/dist/channel-email/email-channel.js +8 -1
  59. package/dist/channel-email/pii-guard.js +6 -5
  60. package/dist/channel-email/smtp-transport.js +22 -1
  61. package/dist/channel-email/types.d.ts +1 -0
  62. package/dist/channel-slack/feature.js +4 -2
  63. package/dist/channel-slack/index.d.ts +1 -1
  64. package/dist/channel-slack/index.js +1 -1
  65. package/dist/channel-slack/slack-channel.d.ts +4 -0
  66. package/dist/channel-slack/slack-channel.js +4 -0
  67. package/dist/channel-teams/feature.js +4 -2
  68. package/dist/channel-teams/index.d.ts +1 -1
  69. package/dist/channel-teams/index.js +1 -1
  70. package/dist/channel-teams/teams-channel.d.ts +4 -0
  71. package/dist/channel-teams/teams-channel.js +4 -0
  72. package/dist/channel-telegram/feature.js +4 -2
  73. package/dist/channel-telegram/index.d.ts +1 -1
  74. package/dist/channel-telegram/index.js +1 -1
  75. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  76. package/dist/channel-telegram/telegram-channel.js +28 -12
  77. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  78. package/dist/delivery/chat-webhook-channel.js +10 -5
  79. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  80. package/dist/delivery/chat-webhook-sender.js +13 -8
  81. package/dist/delivery/delivery-service.d.ts +4 -3
  82. package/dist/delivery/delivery-service.js +53 -33
  83. package/dist/delivery/index.d.ts +2 -2
  84. package/dist/delivery/index.js +2 -2
  85. package/dist/delivery/jobs.js +3 -1
  86. package/dist/delivery/types.d.ts +5 -3
  87. package/dist/legal-pages/feature.d.ts +3 -15
  88. package/dist/legal-pages/feature.js +20 -50
  89. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  90. package/dist/legal-pages/lib/boot-check.js +33 -0
  91. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  92. package/dist/legal-pages/lib/validate-routes.js +17 -0
  93. package/dist/page-render/index.d.ts +2 -1
  94. package/dist/page-render/index.js +1 -0
  95. package/dist/page-render/layout.d.ts +7 -0
  96. package/dist/page-render/layout.js +5 -0
  97. package/dist/page-render/trailing-slash.d.ts +2 -0
  98. package/dist/page-render/trailing-slash.js +15 -0
  99. package/dist/renderer-simple/feature.js +1 -0
  100. package/dist/renderer-simple/index.d.ts +1 -1
  101. package/dist/renderer-simple/index.js +1 -1
  102. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  103. package/dist/renderer-simple/simple-renderer.js +66 -12
  104. package/dist/secrets/announce-secret-change.d.ts +8 -0
  105. package/dist/secrets/announce-secret-change.js +18 -0
  106. package/dist/secrets/feature.js +1 -1
  107. package/dist/secrets/handlers/delete.write.js +2 -0
  108. package/dist/secrets/handlers/set.write.js +6 -1
  109. package/dist/secrets/i18n.d.ts +4 -0
  110. package/dist/secrets/i18n.js +4 -0
  111. package/dist/secrets/secrets-context.d.ts +2 -0
  112. package/dist/secrets/secrets-context.js +3 -0
  113. package/dist/secrets/write-gate.d.ts +5 -1
  114. package/dist/secrets/write-gate.js +24 -1
  115. package/dist/tenant/constants.d.ts +1 -0
  116. package/dist/tenant/constants.js +1 -0
  117. package/dist/tenant/handlers/add-member.write.js +14 -3
  118. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  119. package/dist/tenant/handlers/invitations.query.js +15 -1
  120. package/dist/tenant/index.d.ts +1 -0
  121. package/dist/tenant/index.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +2 -0
  123. package/dist/tenant/invitation-table.js +10 -1
  124. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  125. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  126. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  127. package/dist/tier-engine/feature.js +83 -14
  128. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  129. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  130. package/dist/user/invited-global-roles.d.ts +14 -0
  131. package/dist/user/invited-global-roles.js +35 -0
  132. package/dist/user-data-rights/feature.js +3 -0
  133. package/dist/waitlist/constants.d.ts +33 -0
  134. package/dist/waitlist/constants.js +35 -0
  135. package/dist/waitlist/entity.d.ts +433 -0
  136. package/dist/waitlist/entity.js +64 -0
  137. package/dist/waitlist/feature.d.ts +3 -0
  138. package/dist/waitlist/feature.js +38 -0
  139. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  140. package/dist/waitlist/handlers/invite.write.js +119 -0
  141. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  142. package/dist/waitlist/handlers/list.query.js +10 -0
  143. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  144. package/dist/waitlist/handlers/reject.write.js +80 -0
  145. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  146. package/dist/waitlist/handlers/submit.write.js +112 -0
  147. package/dist/waitlist/i18n.d.ts +6 -0
  148. package/dist/waitlist/i18n.js +31 -0
  149. package/dist/waitlist/index.d.ts +5 -0
  150. package/dist/waitlist/index.js +4 -0
  151. package/dist/waitlist/lib.d.ts +10 -0
  152. package/dist/waitlist/lib.js +24 -0
  153. package/dist/waitlist/mail.d.ts +15 -0
  154. package/dist/waitlist/mail.js +73 -0
  155. package/dist/waitlist/options.d.ts +30 -0
  156. package/dist/waitlist/options.js +8 -0
  157. package/dist/waitlist/payloads.d.ts +14 -0
  158. package/dist/waitlist/payloads.js +47 -0
  159. package/dist/waitlist/screens.d.ts +2 -0
  160. package/dist/waitlist/screens.js +42 -0
  161. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  162. package/dist/waitlist/user-data-hooks.js +71 -0
  163. package/package.json +17 -9
  164. package/src/auth-email-password/changes.json +30 -0
  165. package/src/auth-mfa/changes.json +6 -0
  166. package/src/billing-foundation/changes.json +6 -0
  167. package/src/channel-email/changes.json +6 -0
  168. package/src/channel-telegram/changes.json +9 -0
  169. package/src/delivery/changes.json +7 -0
  170. package/src/files/changes.json +13 -1
  171. package/src/renderer-simple/changes.json +6 -0
  172. package/src/secrets/changes.json +12 -0
  173. package/src/tenant/changes.json +6 -0
  174. package/src/waitlist/changes.json +23 -0
@@ -13,3 +13,6 @@ export declare const AuditQueries: {
13
13
  export declare const AUDIT_LOG_SCREEN_ID: "audit-log";
14
14
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
15
15
  export declare const AUDIT_LOG_DETAIL_SCREEN_ID: "audit-log-detail";
16
+ /** Config key (system scope) for how long `escapeHatchUse` audit events are kept. */
17
+ export declare const ESCAPE_HATCH_RETENTION_DAYS_KEY: "audit:config:escape-hatch-retention-days";
18
+ export declare const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -18,3 +18,6 @@ export const AuditQueries = {
18
18
  export const AUDIT_LOG_SCREEN_ID = "audit-log";
19
19
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
20
20
  export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail";
21
+ /** Config key (system scope) for how long `escapeHatchUse` audit events are kept. */
22
+ export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days";
23
+ export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -1,9 +1,10 @@
1
- import { access, defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
2
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants.js";
1
+ import { access, createSystemConfig, defineFeature, i18nKey, SYSTEM_USER_ID, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries, DEFAULT_ESCAPE_HATCH_RETENTION_DAYS, } from "./constants.js";
3
3
  import { escapeHatchUsedSchema } from "./escape-hatch-audit-sink.js";
4
4
  import { detailsQuery } from "./handlers/details.query.js";
5
5
  import { listQuery } from "./handlers/list.query.js";
6
6
  import { AUDIT_I18N } from "./i18n.js";
7
+ import { runEscapeHatchRetention } from "./run-escape-hatch-retention.js";
7
8
  // Audit feature — exposes a filtered read over the framework's event log.
8
9
  //
9
10
  // Design: the event-store IS the audit trail (every entity write produces
@@ -12,10 +13,11 @@ import { AUDIT_I18N } from "./i18n.js";
12
13
  // the existing `events` table. See handlers/list.query.ts for the filter
13
14
  // surface.
14
15
  //
15
- // Retention lives elsewhere. Events are kept indefinitely as the source of
16
- // truth for state; archive or compress policies are a separate concern
17
- // (tracked with the snapshot/archive infrastructure that already exists in
18
- // the framework).
16
+ // Retention: only `escapeHatchUse` events are pruned (daily job, config key
17
+ // `escapeHatchRetentionDays`). All other events stay as the source of truth
18
+ // for state; archive or compress policies are a separate concern (tracked
19
+ // with the snapshot/archive infrastructure that already exists in the
20
+ // framework).
19
21
  export function createAuditFeature() {
20
22
  return defineFeature("audit", (r) => {
21
23
  r.describe("Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).");
@@ -25,9 +27,34 @@ export function createAuditFeature() {
25
27
  recommended: false,
26
28
  });
27
29
  r.translations({ keys: AUDIT_I18N });
28
- r.requires("tenant", "user");
30
+ r.requires("tenant", "user", "config");
29
31
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
30
32
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
33
+ r.config("escapeHatchRetentionDays", createSystemConfig("number", {
34
+ default: DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
35
+ bounds: { min: 1 },
36
+ }));
37
+ r.job({
38
+ name: "escape-hatch-retention",
39
+ trigger: { cron: "30 3 * * *" },
40
+ concurrency: "skip",
41
+ // pruneEvents needs a transaction-capable connection, which only unsafeRaw() hands a job.
42
+ escapeHatch: {
43
+ reason: "prunes expired escapeHatchUse audit events through pruneEvents",
44
+ grants: ["unsafeRaw"],
45
+ },
46
+ handler: async (_payload, ctx) => {
47
+ if (!ctx.registry) {
48
+ throw new Error("escape-hatch-retention: ctx.registry required (JobContext incomplete)");
49
+ }
50
+ await runEscapeHatchRetention({
51
+ db: ctx.db.unsafeRaw(),
52
+ registry: ctx.registry,
53
+ configResolver: ctx.configResolver,
54
+ userId: ctx._userId ?? SYSTEM_USER_ID,
55
+ });
56
+ },
57
+ });
31
58
  const queries = {
32
59
  list: r.queryHandler(listQuery),
33
60
  details: r.queryHandler(detailsQuery),
@@ -0,0 +1,10 @@
1
+ import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type ConfigResolver, type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type PruneEventsResult } from "@cosmicdrift/kumiko-framework/pipeline";
4
+ export declare function isDbConnection(runner: DbRunner): runner is DbConnection;
5
+ export declare function runEscapeHatchRetention(args: {
6
+ readonly db: DbRunner;
7
+ readonly registry: Registry;
8
+ readonly configResolver: ConfigResolver | undefined;
9
+ readonly userId: string;
10
+ }): Promise<PruneEventsResult>;
@@ -0,0 +1,27 @@
1
+ import { SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
3
+ import { createConfigAccessor } from "../config/index.js";
4
+ import { DEFAULT_ESCAPE_HATCH_RETENTION_DAYS, ESCAPE_HATCH_RETENTION_DAYS_KEY, } from "./constants.js";
5
+ import { ESCAPE_HATCH_USE_AGGREGATE_TYPE } from "./escape-hatch-audit-sink.js";
6
+ // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
7
+ export function isDbConnection(runner) {
8
+ return "begin" in runner && typeof runner.begin === "function";
9
+ }
10
+ async function resolveRetentionDays(args) {
11
+ if (!args.configResolver)
12
+ return DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
13
+ const config = createConfigAccessor(args.registry, args.configResolver, SYSTEM_TENANT_ID, args.userId, args.db);
14
+ const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
15
+ return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
16
+ }
17
+ export async function runEscapeHatchRetention(args) {
18
+ const { db } = args;
19
+ if (!isDbConnection(db)) {
20
+ throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
21
+ }
22
+ const olderThanDays = await resolveRetentionDays({ ...args, db });
23
+ return pruneEvents(db, {
24
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
25
+ olderThanDays,
26
+ });
27
+ }
@@ -0,0 +1,80 @@
1
+ import { makeDispatchSystemWrite, type SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { type TenantId, type WriteResult } 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
+ /** Writes as the system user with the given tenant roles in the seeded
19
+ * tenant; use it when the app gates tenant data on its own roles rather
20
+ * than SystemAdmin. */
21
+ readonly dispatchWriteAs: (args: Pick<SystemDispatchArgs, "handlerQn" | "payload"> & {
22
+ readonly roles: readonly string[];
23
+ }) => Promise<WriteResult>;
24
+ };
25
+ export type BootstrapConfigValue = string | number | boolean;
26
+ export type BootstrapTenant = {
27
+ readonly id: TenantId;
28
+ readonly key: string;
29
+ readonly name: string;
30
+ /** Tenant-scope config values by qualified key (e.g.
31
+ * `"auth-mfa:config:required": "admins"`), written as system writes on
32
+ * EVERY run: config is declarative, so a rerun converges to the plan and
33
+ * a value that already matches is not rewritten. Unlike `seed` it also
34
+ * applies to tenants that already exist. */
35
+ readonly config?: Readonly<Record<string, BootstrapConfigValue>>;
36
+ readonly invites?: readonly BootstrapInvite[];
37
+ };
38
+ export type BootstrapSystemAdmin = {
39
+ readonly email: string;
40
+ /** Home tenant: login needs a membership, so the SystemAdmin is invited
41
+ * into this tenant with `role` and receives the global role on accept. */
42
+ readonly tenantId: TenantId;
43
+ readonly role: string;
44
+ };
45
+ export type BootstrapPlan = {
46
+ readonly tenants: readonly BootstrapTenant[];
47
+ readonly systemAdmins?: readonly BootstrapSystemAdmin[];
48
+ /** Runs for each tenant right after this run created it. An existing
49
+ * tenant is never seeded again — also not when a previous run failed
50
+ * inside the seed (that run fails; clean up by hand). */
51
+ readonly seed?: (deps: BootstrapSeedDeps) => Promise<void>;
52
+ };
53
+ export type BootstrapDeps = {
54
+ readonly db: DbConnection;
55
+ readonly redis: Redis;
56
+ readonly dispatcher: Dispatcher;
57
+ };
58
+ export type BootstrapTenantOutcome = "created" | "exists";
59
+ export type BootstrapInviteOutcome = "invited" | "resent" | "pending" | "active" | "cancelled" | "role-mismatch";
60
+ export type BootstrapReport = {
61
+ readonly tenants: ReadonlyArray<{
62
+ readonly id: TenantId;
63
+ readonly outcome: BootstrapTenantOutcome;
64
+ readonly seeded: boolean;
65
+ /** Qualified keys this run wrote (new or changed); empty on a no-op rerun. */
66
+ readonly configApplied: readonly string[];
67
+ }>;
68
+ readonly invites: ReadonlyArray<{
69
+ readonly tenantId: TenantId;
70
+ readonly email: string;
71
+ readonly globalRoles: readonly GlobalRole[];
72
+ readonly outcome: BootstrapInviteOutcome;
73
+ }>;
74
+ };
75
+ export declare class BootstrapPlanError extends Error {
76
+ }
77
+ export declare class BootstrapWriteError extends Error {
78
+ }
79
+ export declare function bootstrapTenants(deps: BootstrapDeps, plan: BootstrapPlan): Promise<BootstrapReport>;
80
+ export {};
@@ -0,0 +1,170 @@
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?.({
144
+ tenantId: tenant.id,
145
+ db: deps.db,
146
+ dispatchSystemWrite,
147
+ dispatchWriteAs: ({ handlerQn, payload, roles }) => deps.dispatcher.write(handlerQn, payload, createSystemUser(tenant.id, roles)),
148
+ });
149
+ }
150
+ const configApplied = await applyTenantConfig(deps, tenant);
151
+ tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
152
+ }
153
+ const invites = [];
154
+ for (const invite of plannedInvites) {
155
+ const outcome = await decideInvite(deps, invite);
156
+ if (isMailingOutcome(outcome)) {
157
+ const result = await deps.dispatcher.write(AuthHandlers.systemInviteCreate, { email: invite.email, role: invite.role, globalRoles: invite.globalRoles }, createSystemUser(invite.tenantId));
158
+ if (!result.isSuccess) {
159
+ throw new BootstrapWriteError(`inviting ${invite.email} into tenant ${invite.tenantId} failed: ${result.error.code} — ${result.error.message}`);
160
+ }
161
+ }
162
+ invites.push({
163
+ tenantId: invite.tenantId,
164
+ email: invite.email,
165
+ globalRoles: invite.globalRoles,
166
+ outcome,
167
+ });
168
+ }
169
+ return { tenants, invites };
170
+ }
@@ -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 {};