@cosmicdrift/kumiko-samples 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 (124) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/constants.ts +5 -0
  4. package/packages/bundled-features/src/audit/feature.ts +47 -6
  5. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
  6. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +325 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +30 -0
  8. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  10. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  15. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  17. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  18. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  21. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  22. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  23. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  24. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  25. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  26. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  27. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  28. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  29. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  30. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  31. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  32. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  33. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  34. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  37. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  38. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  39. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  40. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  44. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  45. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  46. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  47. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  48. package/packages/bundled-features/src/channel-email/email-channel.ts +9 -1
  49. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  50. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  51. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  52. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  53. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  54. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  55. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  56. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  57. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  58. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  59. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  60. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  61. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  62. package/packages/bundled-features/src/delivery/changes.json +7 -0
  63. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  64. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  65. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  66. package/packages/bundled-features/src/delivery/index.ts +4 -0
  67. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  68. package/packages/bundled-features/src/delivery/types.ts +10 -4
  69. package/packages/bundled-features/src/files/changes.json +13 -1
  70. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  71. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  72. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  73. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  74. package/packages/bundled-features/src/page-render/index.ts +2 -0
  75. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  76. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  77. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  78. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  79. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  80. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  81. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  82. package/packages/bundled-features/src/secrets/changes.json +12 -0
  83. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  84. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  85. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  86. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  87. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  88. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  89. package/packages/bundled-features/src/tenant/changes.json +6 -0
  90. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  91. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  92. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  93. package/packages/bundled-features/src/tenant/index.ts +1 -0
  94. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  97. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  99. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  100. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  101. package/packages/bundled-features/src/waitlist/changes.json +23 -0
  102. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  103. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  104. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  105. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  106. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  107. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  108. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  109. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  110. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  111. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  112. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  113. package/packages/bundled-features/src/waitlist/options.ts +42 -0
  114. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  115. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  116. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  117. package/samples/apps/use-all-bundled/feature-manifest.json +63 -7
  118. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  119. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  120. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  121. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  122. package/samples/recipes/chat-channels/README.md +2 -0
  123. package/samples/recipes/managed-config/README.md +1 -1
  124. 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.342.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.342.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"
@@ -24,3 +24,8 @@ export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
24
24
 
25
25
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
26
26
  export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail" as const;
27
+
28
+ /** Config key (system scope) for how long `escapeHatchUse` audit events are kept. */
29
+ export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days" as const;
30
+
31
+ export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -1,14 +1,22 @@
1
1
  import {
2
2
  access,
3
+ createSystemConfig,
3
4
  defineFeature,
4
5
  type FeatureDefinition,
5
6
  i18nKey,
7
+ SYSTEM_USER_ID,
6
8
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants.js";
9
+ import {
10
+ AUDIT_LOG_DETAIL_SCREEN_ID,
11
+ AUDIT_LOG_SCREEN_ID,
12
+ AuditQueries,
13
+ DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
14
+ } from "./constants.js";
8
15
  import { escapeHatchUsedSchema } from "./escape-hatch-audit-sink.js";
9
16
  import { detailsQuery } from "./handlers/details.query.js";
10
17
  import { listQuery } from "./handlers/list.query.js";
11
18
  import { AUDIT_I18N } from "./i18n.js";
19
+ import { runEscapeHatchRetention } from "./run-escape-hatch-retention.js";
12
20
 
13
21
  // Audit feature — exposes a filtered read over the framework's event log.
14
22
  //
@@ -18,10 +26,11 @@ import { AUDIT_I18N } from "./i18n.js";
18
26
  // the existing `events` table. See handlers/list.query.ts for the filter
19
27
  // surface.
20
28
  //
21
- // Retention lives elsewhere. Events are kept indefinitely as the source of
22
- // truth for state; archive or compress policies are a separate concern
23
- // (tracked with the snapshot/archive infrastructure that already exists in
24
- // the framework).
29
+ // Retention: only `escapeHatchUse` events are pruned (daily job, config key
30
+ // `escapeHatchRetentionDays`). All other events stay as the source of truth
31
+ // for state; archive or compress policies are a separate concern (tracked
32
+ // with the snapshot/archive infrastructure that already exists in the
33
+ // framework).
25
34
  export function createAuditFeature(): FeatureDefinition {
26
35
  return defineFeature(
27
36
  "audit",
@@ -35,11 +44,43 @@ export function createAuditFeature(): FeatureDefinition {
35
44
  recommended: false,
36
45
  });
37
46
  r.translations({ keys: AUDIT_I18N });
38
- r.requires("tenant", "user");
47
+ r.requires("tenant", "user", "config");
39
48
 
40
49
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
41
50
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
42
51
 
52
+ r.config(
53
+ "escapeHatchRetentionDays",
54
+ createSystemConfig("number", {
55
+ default: DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
56
+ bounds: { min: 1 },
57
+ }),
58
+ );
59
+
60
+ r.job({
61
+ name: "escape-hatch-retention",
62
+ trigger: { cron: "30 3 * * *" },
63
+ concurrency: "skip",
64
+ // pruneEvents needs a transaction-capable connection, which only unsafeRaw() hands a job.
65
+ escapeHatch: {
66
+ reason: "prunes expired escapeHatchUse audit events through pruneEvents",
67
+ grants: ["unsafeRaw"],
68
+ },
69
+ handler: async (_payload, ctx) => {
70
+ if (!ctx.registry) {
71
+ throw new Error(
72
+ "escape-hatch-retention: ctx.registry required (JobContext incomplete)",
73
+ );
74
+ }
75
+ await runEscapeHatchRetention({
76
+ db: ctx.db.unsafeRaw(),
77
+ registry: ctx.registry,
78
+ configResolver: ctx.configResolver,
79
+ userId: ctx._userId ?? SYSTEM_USER_ID,
80
+ });
81
+ },
82
+ });
83
+
43
84
  const queries = {
44
85
  list: r.queryHandler(listQuery),
45
86
  details: r.queryHandler(detailsQuery),
@@ -0,0 +1,53 @@
1
+ import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ type ConfigResolver,
4
+ type Registry,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { type PruneEventsResult, pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
8
+ import { createConfigAccessor } from "../config/index.js";
9
+ import {
10
+ DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
11
+ ESCAPE_HATCH_RETENTION_DAYS_KEY,
12
+ } from "./constants.js";
13
+ import { ESCAPE_HATCH_USE_AGGREGATE_TYPE } from "./escape-hatch-audit-sink.js";
14
+
15
+ // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
16
+ export function isDbConnection(runner: DbRunner): runner is DbConnection {
17
+ return "begin" in runner && typeof runner.begin === "function";
18
+ }
19
+
20
+ async function resolveRetentionDays(args: {
21
+ readonly registry: Registry;
22
+ readonly configResolver: ConfigResolver | undefined;
23
+ readonly db: DbConnection;
24
+ readonly userId: string;
25
+ }): Promise<number> {
26
+ if (!args.configResolver) return DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
27
+ const config = createConfigAccessor(
28
+ args.registry,
29
+ args.configResolver,
30
+ SYSTEM_TENANT_ID,
31
+ args.userId,
32
+ args.db,
33
+ );
34
+ const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
35
+ return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
36
+ }
37
+
38
+ export async function runEscapeHatchRetention(args: {
39
+ readonly db: DbRunner;
40
+ readonly registry: Registry;
41
+ readonly configResolver: ConfigResolver | undefined;
42
+ readonly userId: string;
43
+ }): Promise<PruneEventsResult> {
44
+ const { db } = args;
45
+ if (!isDbConnection(db)) {
46
+ throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
47
+ }
48
+ const olderThanDays = await resolveRetentionDays({ ...args, db });
49
+ return pruneEvents(db, {
50
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
51
+ olderThanDays,
52
+ });
53
+ }
@@ -0,0 +1,325 @@
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 {
9
+ makeDispatchSystemWrite,
10
+ type SystemDispatchArgs,
11
+ } from "@cosmicdrift/kumiko-framework/api";
12
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
+ import {
15
+ ConfigScopes,
16
+ createSystemUser,
17
+ type TenantId,
18
+ type WriteResult,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
21
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
22
+ import type { Redis } from "ioredis";
23
+ // kumiko-lint-ignore cross-feature-import bootstrap sets per-tenant config through the config feature's write handler
24
+ import { ConfigHandlers } from "../config/constants.js";
25
+ // kumiko-lint-ignore cross-feature-import bootstrap compares against the stored config row to stay idempotent
26
+ import { findConfigRow } from "../config/write-helpers.js";
27
+ // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
28
+ import { TenantHandlers } from "../tenant/constants.js";
29
+ // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
30
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../tenant/invitation-table.js";
31
+ // kumiko-lint-ignore cross-feature-import bootstrap checks existing memberships
32
+ import { tenantMembershipsTable } from "../tenant/membership-table.js";
33
+ // kumiko-lint-ignore cross-feature-import bootstrap checks for an existing tenant
34
+ import { tenantTable } from "../tenant/schema/tenant.js";
35
+ // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
36
+ import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
37
+ // kumiko-lint-ignore cross-feature-import bootstrap skips already activated users
38
+ import { userTable } from "../user/schema/user.js";
39
+ import { AuthHandlers } from "./constants.js";
40
+ import { hasLiveInviteToken } from "./invite-token-store.js";
41
+
42
+ type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
43
+
44
+ export type BootstrapInvite = {
45
+ readonly email: string;
46
+ /** Membership role in the tenant, validated like a tenant-admin invite
47
+ * (reserved roles such as SystemAdmin are rejected here). */
48
+ readonly role: string;
49
+ };
50
+
51
+ export type BootstrapSeedDeps = {
52
+ readonly tenantId: TenantId;
53
+ readonly db: DbConnection;
54
+ readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
55
+ /** Writes as the system user with the given tenant roles in the seeded
56
+ * tenant; use it when the app gates tenant data on its own roles rather
57
+ * than SystemAdmin. */
58
+ readonly dispatchWriteAs: (
59
+ args: Pick<SystemDispatchArgs, "handlerQn" | "payload"> & { readonly roles: readonly string[] },
60
+ ) => Promise<WriteResult>;
61
+ };
62
+
63
+ export type BootstrapConfigValue = string | number | boolean;
64
+
65
+ export type BootstrapTenant = {
66
+ readonly id: TenantId;
67
+ readonly key: string;
68
+ readonly name: string;
69
+ /** Tenant-scope config values by qualified key (e.g.
70
+ * `"auth-mfa:config:required": "admins"`), written as system writes on
71
+ * EVERY run: config is declarative, so a rerun converges to the plan and
72
+ * a value that already matches is not rewritten. Unlike `seed` it also
73
+ * applies to tenants that already exist. */
74
+ readonly config?: Readonly<Record<string, BootstrapConfigValue>>;
75
+ readonly invites?: readonly BootstrapInvite[];
76
+ };
77
+
78
+ export type BootstrapSystemAdmin = {
79
+ readonly email: string;
80
+ /** Home tenant: login needs a membership, so the SystemAdmin is invited
81
+ * into this tenant with `role` and receives the global role on accept. */
82
+ readonly tenantId: TenantId;
83
+ readonly role: string;
84
+ };
85
+
86
+ export type BootstrapPlan = {
87
+ readonly tenants: readonly BootstrapTenant[];
88
+ readonly systemAdmins?: readonly BootstrapSystemAdmin[];
89
+ /** Runs for each tenant right after this run created it. An existing
90
+ * tenant is never seeded again — also not when a previous run failed
91
+ * inside the seed (that run fails; clean up by hand). */
92
+ readonly seed?: (deps: BootstrapSeedDeps) => Promise<void>;
93
+ };
94
+
95
+ export type BootstrapDeps = {
96
+ readonly db: DbConnection;
97
+ readonly redis: Redis;
98
+ readonly dispatcher: Dispatcher;
99
+ };
100
+
101
+ export type BootstrapTenantOutcome = "created" | "exists";
102
+
103
+ export type BootstrapInviteOutcome =
104
+ // fresh invitation mailed
105
+ | "invited"
106
+ // the previous invitation expired unused, a new link was mailed
107
+ | "resent"
108
+ // a still-valid invitation is pending, nothing sent
109
+ | "pending"
110
+ // the invitee accepted earlier or already holds the access
111
+ | "active"
112
+ // a tenant admin cancelled the invitation; bootstrap respects that
113
+ | "cancelled"
114
+ // a tenant admin re-invited the address without the planned global roles;
115
+ // bootstrap does not override that, the operator has to decide
116
+ | "role-mismatch";
117
+
118
+ export type BootstrapReport = {
119
+ readonly tenants: ReadonlyArray<{
120
+ readonly id: TenantId;
121
+ readonly outcome: BootstrapTenantOutcome;
122
+ readonly seeded: boolean;
123
+ /** Qualified keys this run wrote (new or changed); empty on a no-op rerun. */
124
+ readonly configApplied: readonly string[];
125
+ }>;
126
+ readonly invites: ReadonlyArray<{
127
+ readonly tenantId: TenantId;
128
+ readonly email: string;
129
+ readonly globalRoles: readonly GlobalRole[];
130
+ readonly outcome: BootstrapInviteOutcome;
131
+ }>;
132
+ };
133
+
134
+ type PlannedInvite = {
135
+ readonly tenantId: TenantId;
136
+ readonly email: string;
137
+ readonly role: string;
138
+ readonly globalRoles: readonly GlobalRole[];
139
+ };
140
+
141
+ type InvitationRow = {
142
+ readonly id: string;
143
+ readonly status: string;
144
+ readonly globalRoles: unknown;
145
+ };
146
+ type UserRow = { readonly id: string; readonly roles: unknown };
147
+
148
+ export class BootstrapPlanError extends Error {}
149
+
150
+ export class BootstrapWriteError extends Error {}
151
+
152
+ function planInvites(plan: BootstrapPlan): readonly PlannedInvite[] {
153
+ const tenantIds = new Set(plan.tenants.map((t) => t.id));
154
+ const invites: PlannedInvite[] = [];
155
+ for (const admin of plan.systemAdmins ?? []) {
156
+ if (!tenantIds.has(admin.tenantId)) {
157
+ throw new BootstrapPlanError(
158
+ `systemAdmins: ${admin.email} references tenant ${admin.tenantId}, which is not in tenants`,
159
+ );
160
+ }
161
+ invites.push({
162
+ tenantId: admin.tenantId,
163
+ email: admin.email.toLowerCase(),
164
+ role: admin.role,
165
+ globalRoles: ["SystemAdmin"],
166
+ });
167
+ }
168
+ for (const tenant of plan.tenants) {
169
+ for (const invite of tenant.invites ?? []) {
170
+ invites.push({
171
+ tenantId: tenant.id,
172
+ email: invite.email.toLowerCase(),
173
+ role: invite.role,
174
+ globalRoles: [],
175
+ });
176
+ }
177
+ }
178
+ // One invitation row per (tenant, email): a second entry would overwrite
179
+ // the first one's role or global roles.
180
+ const seen = new Set<string>();
181
+ for (const invite of invites) {
182
+ const key = `${invite.tenantId}\u0000${invite.email}`;
183
+ if (seen.has(key)) {
184
+ throw new BootstrapPlanError(
185
+ `${invite.email} is listed twice for tenant ${invite.tenantId} (systemAdmins and tenants[].invites share one invitation per tenant)`,
186
+ );
187
+ }
188
+ seen.add(key);
189
+ }
190
+ return invites;
191
+ }
192
+
193
+ async function ensureTenant(
194
+ deps: BootstrapDeps,
195
+ tenant: BootstrapTenant,
196
+ ): Promise<BootstrapTenantOutcome> {
197
+ const existing = await fetchOne(deps.db, tenantTable, { id: tenant.id });
198
+ if (existing) return "exists";
199
+ const result = await deps.dispatcher.write(
200
+ TenantHandlers.create,
201
+ { id: tenant.id, key: tenant.key, name: tenant.name },
202
+ createSystemUser(tenant.id),
203
+ );
204
+ if (!result.isSuccess) {
205
+ throw new BootstrapWriteError(
206
+ `creating tenant ${tenant.key} (${tenant.id}) failed: ${result.error.code} — ${result.error.message}`,
207
+ );
208
+ }
209
+ return "created";
210
+ }
211
+
212
+ async function applyTenantConfig(
213
+ deps: BootstrapDeps,
214
+ tenant: BootstrapTenant,
215
+ ): Promise<readonly string[]> {
216
+ const applied: string[] = [];
217
+ for (const [key, value] of Object.entries(tenant.config ?? {})) {
218
+ const existing = await findConfigRow(deps.db, key, tenant.id, null);
219
+ if (existing?.value === JSON.stringify(value)) continue;
220
+ const result = await deps.dispatcher.write(
221
+ ConfigHandlers.set,
222
+ { key, value, scope: ConfigScopes.tenant },
223
+ // SystemAdmin on top of the system role: keys with the default tenant
224
+ // write access (admin roles) reject a bare system user, system-only keys
225
+ // accept it.
226
+ createSystemUser(tenant.id, ["SystemAdmin"]),
227
+ );
228
+ if (!result.isSuccess) {
229
+ throw new BootstrapWriteError(
230
+ `setting config ${key} for tenant ${tenant.id} failed: ${result.error.code} — ${result.error.message}`,
231
+ );
232
+ }
233
+ applied.push(key);
234
+ }
235
+ return applied;
236
+ }
237
+
238
+ async function holdsInvitedAccess(deps: BootstrapDeps, invite: PlannedInvite): Promise<boolean> {
239
+ const user = await fetchOne<UserRow>(deps.db, userTable, {
240
+ email: invite.email,
241
+ isDeleted: false,
242
+ });
243
+ if (!user) return false;
244
+ const membership = await fetchOne(deps.db, tenantMembershipsTable, {
245
+ userId: user.id,
246
+ tenantId: invite.tenantId,
247
+ });
248
+ if (!membership) return false;
249
+ const userGlobalRoles = parseRoles(user.roles);
250
+ return invite.globalRoles.every((role) => userGlobalRoles.includes(role));
251
+ }
252
+
253
+ async function decideInvite(
254
+ deps: BootstrapDeps,
255
+ invite: PlannedInvite,
256
+ ): Promise<BootstrapInviteOutcome> {
257
+ if (await holdsInvitedAccess(deps, invite)) return "active";
258
+ const invitation = await fetchOne<InvitationRow>(deps.db, tenantInvitationsTable, {
259
+ tenantId: invite.tenantId,
260
+ email: invite.email,
261
+ });
262
+ if (!invitation) return "invited";
263
+ if (invitation.status === INVITATION_STATUS.cancelled) return "cancelled";
264
+ const storedGlobalRoles = parseRoles(invitation.globalRoles);
265
+ if (!invite.globalRoles.every((role) => storedGlobalRoles.includes(role))) {
266
+ return "role-mismatch";
267
+ }
268
+ if (invitation.status === INVITATION_STATUS.accepted) return "active";
269
+ if (await hasLiveInviteToken(deps.redis, invitation.id)) return "pending";
270
+ return "resent";
271
+ }
272
+
273
+ function isMailingOutcome(outcome: BootstrapInviteOutcome): boolean {
274
+ return outcome === "invited" || outcome === "resent";
275
+ }
276
+
277
+ export async function bootstrapTenants(
278
+ deps: BootstrapDeps,
279
+ plan: BootstrapPlan,
280
+ ): Promise<BootstrapReport> {
281
+ const plannedInvites = planInvites(plan);
282
+ const dispatchSystemWrite = makeDispatchSystemWrite(deps.dispatcher);
283
+
284
+ const tenants: Array<BootstrapReport["tenants"][number]> = [];
285
+ for (const tenant of plan.tenants) {
286
+ const outcome = await ensureTenant(deps, tenant);
287
+ const shouldSeed = outcome === "created" && plan.seed !== undefined;
288
+ if (shouldSeed) {
289
+ await plan.seed?.({
290
+ tenantId: tenant.id,
291
+ db: deps.db,
292
+ dispatchSystemWrite,
293
+ dispatchWriteAs: ({ handlerQn, payload, roles }) =>
294
+ deps.dispatcher.write(handlerQn, payload, createSystemUser(tenant.id, roles)),
295
+ });
296
+ }
297
+ const configApplied = await applyTenantConfig(deps, tenant);
298
+ tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
299
+ }
300
+
301
+ const invites: Array<BootstrapReport["invites"][number]> = [];
302
+ for (const invite of plannedInvites) {
303
+ const outcome = await decideInvite(deps, invite);
304
+ if (isMailingOutcome(outcome)) {
305
+ const result = await deps.dispatcher.write(
306
+ AuthHandlers.systemInviteCreate,
307
+ { email: invite.email, role: invite.role, globalRoles: invite.globalRoles },
308
+ createSystemUser(invite.tenantId),
309
+ );
310
+ if (!result.isSuccess) {
311
+ throw new BootstrapWriteError(
312
+ `inviting ${invite.email} into tenant ${invite.tenantId} failed: ${result.error.code} — ${result.error.message}`,
313
+ );
314
+ }
315
+ }
316
+ invites.push({
317
+ tenantId: invite.tenantId,
318
+ email: invite.email,
319
+ globalRoles: invite.globalRoles,
320
+ outcome,
321
+ });
322
+ }
323
+
324
+ return { tenants, invites };
325
+ }
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "AuthCard styling hooks (className, headerClassName, titleClassName, bodyClassName)",
6
+ "detail": "AuthCard takes className, headerClassName, titleClassName and bodyClassName\n`AuthCard` renders the body wrapper (`p-6 pt-0 flex flex-col gap-4`) itself and accepts `className` (Card), `headerClassName`, `titleClassName` and `bodyClassName`, merged with tailwind-merge over the defaults. The auth-email-password and auth-mfa screens no longer carry their own body wrapper; the rendered DOM stays the same. The session bootstrap error screen keeps its former bottom padding through `bodyClassName`."
7
+ },
8
+ {
9
+ "version": "0.342.0",
10
+ "type": "improvement",
11
+ "title": "Bootstrap seed can write with app tenant roles",
12
+ "detail": "Bootstrap seed gets `dispatchWriteAs` to write with app tenant roles instead of SystemAdmin."
13
+ },
14
+ {
15
+ "version": "0.341.0",
16
+ "type": "breaking",
17
+ "title": "Invitations can carry a global role (SystemAdmin), granted only on accept",
18
+ "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.",
19
+ "migration": "New column read_tenant_invitations.global_roles: run `kumiko schema generate` and apply the migration before deploying."
20
+ },
21
+ {
22
+ "version": "0.341.0",
23
+ "type": "improvement",
24
+ "title": "runBootstrap applies per-tenant config",
25
+ "detail": "BootstrapTenant.config sets tenant-scope config keys as system writes on every run; a value that already matches is not rewritten."
26
+ },
27
+ {
28
+ "version": "0.341.0",
29
+ "type": "fix",
30
+ "title": "System invite refuses to reset an accepted invitation (invite_already_accepted)"
31
+ },
2
32
  {
3
33
  "version": "0.339.0",
4
34
  "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