@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
@@ -0,0 +1,15 @@
1
+ export {
2
+ DEFAULT_OWN_TENANT_INVITE_ROLE,
3
+ WAITLIST_FEATURE,
4
+ WAITLIST_FIELD_LIMITS,
5
+ WAITLIST_NOTIFICATION_TYPES,
6
+ WAITLIST_STATUS,
7
+ WaitlistErrors,
8
+ WaitlistHandlers,
9
+ WaitlistQueries,
10
+ type WaitlistStatus,
11
+ } from "./constants.js";
12
+ export { isOpenWaitlistStatus, waitlistEntryEntity, waitlistEntryTable } from "./entity.js";
13
+ export { createWaitlistFeature } from "./feature.js";
14
+ export type { WaitlistInviteOptions, WaitlistOptions } from "./options.js";
15
+ export { type WaitlistSubmitInput, WaitlistSubmitSchema } from "./payloads.js";
@@ -0,0 +1,36 @@
1
+ import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ access,
4
+ createSystemUser,
5
+ type HandlerContext,
6
+ type SessionUser,
7
+ SYSTEM_TENANT_ID,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { waitlistEntryEntity, waitlistEntryTable } from "./entity.js";
11
+
12
+ export const WAITLIST_CROSS_TENANT_REASON = "waitlist is platform-wide (systemScope)";
13
+
14
+ export const adminAccess = { roles: access.systemAdmin } as const;
15
+
16
+ export const waitlistExecutor = createEventStoreExecutor(waitlistEntryTable, waitlistEntryEntity, {
17
+ entityName: "waitlistEntry",
18
+ });
19
+
20
+ export function normalizeEmail(email: string): string {
21
+ return email.toLowerCase();
22
+ }
23
+
24
+ export function waitlistDb(ctx: HandlerContext): TenantDb {
25
+ if (!ctx.systemDb) {
26
+ throw new InternalError({
27
+ message: "waitlist requires ctx.systemDb — is r.systemScope() set on the feature?",
28
+ });
29
+ }
30
+ return ctx.systemDb.acknowledgeCrossTenant(WAITLIST_CROSS_TENANT_REASON);
31
+ }
32
+
33
+ // Entries are systemStream rows, so every write targets the system tenant's stream.
34
+ export function platformActor(): SessionUser {
35
+ return createSystemUser(SYSTEM_TENANT_ID);
36
+ }
@@ -0,0 +1,114 @@
1
+ import type { AuthMailContent } from "../auth-email-password/index.js";
2
+
3
+ export type WaitlistMailLocale = "en" | "de" | "es";
4
+
5
+ type ConfirmationStrings = {
6
+ readonly subject: (app: string | undefined) => string;
7
+ readonly header: string;
8
+ readonly intro: (app: string | undefined) => string;
9
+ readonly footer: (app: string | undefined) => string;
10
+ };
11
+
12
+ const CONFIRMATION_STRINGS: Readonly<Record<WaitlistMailLocale, ConfirmationStrings>> = {
13
+ en: {
14
+ subject: (app) => (app ? `You're on the ${app} waitlist` : "You're on the waitlist"),
15
+ header: "Waitlist confirmed",
16
+ intro: (app) =>
17
+ `Hi, we received your request${app ? ` for ${app}` : ""} and look at it personally. We'll email you when you can get started.`,
18
+ footer: (app) => `If you didn't ask to join${app ? ` ${app}` : ""}, you can ignore this email.`,
19
+ },
20
+ de: {
21
+ subject: (app) =>
22
+ app ? `Du stehst auf der ${app}-Warteliste` : "Du stehst auf der Warteliste",
23
+ header: "Warteliste bestätigt",
24
+ intro: (app) =>
25
+ `Hallo, wir haben deine Anfrage${app ? ` für ${app}` : ""} erhalten und schauen sie uns persönlich an. Wir melden uns per E-Mail, sobald du loslegen kannst.`,
26
+ footer: (app) =>
27
+ `Falls du dich nicht${app ? ` für ${app}` : ""} angemeldet hast, kannst du diese E-Mail ignorieren.`,
28
+ },
29
+ es: {
30
+ subject: (app) =>
31
+ app ? `Estás en la lista de espera de ${app}` : "Estás en la lista de espera",
32
+ header: "Lista de espera confirmada",
33
+ intro: (app) =>
34
+ `Hola, hemos recibido tu solicitud${app ? ` para ${app}` : ""} y la revisamos personalmente. Te avisaremos por correo cuando puedas empezar.`,
35
+ footer: (app) =>
36
+ `Si no solicitaste unirte${app ? ` a ${app}` : ""}, puedes ignorar este correo.`,
37
+ },
38
+ };
39
+
40
+ type AdminNoticeStrings = {
41
+ readonly subject: (name: string) => string;
42
+ readonly header: string;
43
+ readonly intro: string;
44
+ readonly footer: string;
45
+ };
46
+
47
+ const ADMIN_NOTICE_STRINGS: Readonly<Record<"en" | "de", AdminNoticeStrings>> = {
48
+ en: {
49
+ subject: (name) => `New waitlist entry: ${name}`,
50
+ header: "New waitlist entry",
51
+ intro: "Someone just joined the waitlist.",
52
+ footer: "Review the entry in the admin waitlist screen.",
53
+ },
54
+ de: {
55
+ subject: (name) => `Neuer Wartelisten-Eintrag: ${name}`,
56
+ header: "Neuer Wartelisten-Eintrag",
57
+ intro: "Jemand hat sich gerade auf die Warteliste eingetragen.",
58
+ footer: "Den Eintrag findest du in der Wartelisten-Verwaltung.",
59
+ },
60
+ };
61
+
62
+ function languageOf(locale: string | undefined): string {
63
+ return (locale ?? "").split("-")[0]?.toLowerCase() ?? "";
64
+ }
65
+
66
+ function isConfirmationLocale(language: string): language is WaitlistMailLocale {
67
+ return language === "en" || language === "de" || language === "es";
68
+ }
69
+
70
+ export function resolveWaitlistMailLocale(locale: string | undefined): WaitlistMailLocale {
71
+ const language = languageOf(locale);
72
+ return isConfirmationLocale(language) ? language : "en";
73
+ }
74
+
75
+ // The submitter's name stays out: the address is unverified, so any text in
76
+ // this mail could be planted by whoever typed someone else's email.
77
+ export function renderWaitlistConfirmationEmail(args: {
78
+ readonly locale: string | undefined;
79
+ readonly appName?: string | undefined;
80
+ }): AuthMailContent {
81
+ const t = CONFIRMATION_STRINGS[resolveWaitlistMailLocale(args.locale)];
82
+ return {
83
+ subject: t.subject(args.appName),
84
+ header: t.header,
85
+ sections: [{ text: t.intro(args.appName) }],
86
+ footer: t.footer(args.appName),
87
+ };
88
+ }
89
+
90
+ // Operator-facing, so the entrant's locale does not apply: en/de, en default.
91
+ export function resolveWaitlistAdminMailLocale(adminLocale: string | undefined): "en" | "de" {
92
+ return languageOf(adminLocale) === "de" ? "de" : "en";
93
+ }
94
+
95
+ export function renderWaitlistAdminNoticeEmail(args: {
96
+ readonly name: string;
97
+ readonly email: string;
98
+ readonly company: string | undefined;
99
+ readonly message: string | undefined;
100
+ readonly adminLocale: string | undefined;
101
+ }): AuthMailContent {
102
+ const t = ADMIN_NOTICE_STRINGS[resolveWaitlistAdminMailLocale(args.adminLocale)];
103
+ const details = [
104
+ `${args.name} <${args.email}>`,
105
+ ...(args.company ? [args.company] : []),
106
+ ...(args.message ? [args.message] : []),
107
+ ];
108
+ return {
109
+ subject: t.subject(args.name),
110
+ header: t.header,
111
+ sections: [{ text: t.intro }, { text: details.join("\n") }],
112
+ footer: t.footer,
113
+ };
114
+ }
@@ -0,0 +1,42 @@
1
+ import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { PayloadRateLimitOption, RateLimitOption } from "@cosmicdrift/kumiko-types/handlers";
3
+
4
+ export type WaitlistInviteOptions =
5
+ | {
6
+ /** Each invited entry gets its own tenant, named after company or name. */
7
+ readonly mode: "own-tenant";
8
+ /** Membership role in the new tenant. Default "TenantAdmin". */
9
+ readonly role?: string;
10
+ }
11
+ | {
12
+ /** Every invited entry joins one existing tenant. */
13
+ readonly mode: "shared-tenant";
14
+ readonly tenantId: TenantId;
15
+ readonly role: string;
16
+ };
17
+
18
+ export type WaitlistOptions = {
19
+ /** Address for an admin notification mail on each new entry. Null, an empty
20
+ * string or an absent option sends none. Best effort, never fails the submit,
21
+ * also when the resolver throws or rejects. */
22
+ readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
23
+ /** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
24
+ readonly invite?: WaitlistInviteOptions;
25
+ /** Default: 5 submits per IP per 10 minutes. */
26
+ readonly rateLimit?: RateLimitOption;
27
+ /** Default: 3 submits per email address per day, across all IPs. Stops a botnet
28
+ * from flooding one address with confirmation mails. Pass `[]` to disable. */
29
+ readonly emailRateLimits?: readonly PayloadRateLimitOption[];
30
+ /** Shown in the confirmation mail; omitted when unset. */
31
+ readonly appName?: string;
32
+ };
33
+
34
+ export const DEFAULT_SUBMIT_RATE_LIMIT: RateLimitOption = {
35
+ per: "ip+handler",
36
+ limit: 5,
37
+ windowSeconds: 10 * 60,
38
+ };
39
+
40
+ export const DEFAULT_SUBMIT_EMAIL_RATE_LIMITS: readonly PayloadRateLimitOption[] = [
41
+ { per: { payloadField: "email" }, limit: 3, windowSeconds: 24 * 60 * 60 },
42
+ ];
@@ -0,0 +1,55 @@
1
+ import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
2
+ import * as z from "zod";
3
+ import { WAITLIST_FIELD_LIMITS } from "./constants.js";
4
+
5
+ // Submitted text lands in a mail subject; CR/LF there would allow header injection.
6
+ const NO_CONTROL_CHARS = /^\P{Cc}*$/u;
7
+ const NO_CONTROL_CHARS_EXCEPT_WHITESPACE = /^(?:[^\p{Cc}]|[\n\r\t])*$/u;
8
+ const noControlChars = (value: string) => NO_CONTROL_CHARS.test(value);
9
+
10
+ export const WaitlistSubmitSchema = z
11
+ .object({
12
+ name: z
13
+ .string()
14
+ .trim()
15
+ .min(1)
16
+ .max(WAITLIST_FIELD_LIMITS.name)
17
+ .refine(noControlChars, "control characters not allowed"),
18
+ email: z.email().max(WAITLIST_FIELD_LIMITS.email),
19
+ company: z
20
+ .string()
21
+ .trim()
22
+ .min(1)
23
+ .max(WAITLIST_FIELD_LIMITS.company)
24
+ .refine(noControlChars, "control characters not allowed")
25
+ .optional(),
26
+ portfolio: z
27
+ .string()
28
+ .trim()
29
+ .min(1)
30
+ .max(WAITLIST_FIELD_LIMITS.portfolio)
31
+ .refine(noControlChars, "control characters not allowed")
32
+ .optional(),
33
+ message: z
34
+ .string()
35
+ .trim()
36
+ .min(1)
37
+ .max(WAITLIST_FIELD_LIMITS.message)
38
+ .refine(
39
+ (value) => NO_CONTROL_CHARS_EXCEPT_WHITESPACE.test(value),
40
+ "control characters not allowed",
41
+ )
42
+ .optional(),
43
+ locale: z
44
+ .string()
45
+ .max(WAITLIST_FIELD_LIMITS.locale)
46
+ .refine(isValidLocaleTag, "invalid locale tag")
47
+ .transform(canonicalizeLocaleTag),
48
+ // Honeypot: bots fill it, humans leave it empty.
49
+ website: z.string().max(WAITLIST_FIELD_LIMITS.honeypot).optional(),
50
+ })
51
+ .strict();
52
+
53
+ export type WaitlistSubmitInput = z.infer<typeof WaitlistSubmitSchema>;
54
+
55
+ export const WaitlistIdSchema = z.object({ id: z.uuid() }).strict();
@@ -0,0 +1,43 @@
1
+ import { type EntityListScreenDefinition, i18nKey } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { WAITLIST_STATUS, WaitlistHandlers } from "./constants.js";
3
+ import { adminAccess } from "./lib.js";
4
+
5
+ export const waitlistListScreen: EntityListScreenDefinition = {
6
+ id: "waitlist-list",
7
+ type: "entityList",
8
+ entity: "waitlistEntry",
9
+ columns: ["name", "email", "company", "status", "locale", "submittedAt"],
10
+ defaultSort: { field: "submittedAt", dir: "desc" },
11
+ access: adminAccess,
12
+ searchable: true,
13
+ rowActions: [
14
+ {
15
+ id: "invite",
16
+ label: i18nKey("waitlist.action.invite"),
17
+ handler: WaitlistHandlers.invite,
18
+ visible: { field: "status", eq: WAITLIST_STATUS.Pending },
19
+ },
20
+ {
21
+ id: "re-invite",
22
+ label: i18nKey("waitlist.action.reInvite"),
23
+ handler: WaitlistHandlers.invite,
24
+ visible: { field: "status", eq: WAITLIST_STATUS.Invited },
25
+ },
26
+ {
27
+ id: "reject",
28
+ label: i18nKey("waitlist.action.reject"),
29
+ handler: WaitlistHandlers.reject,
30
+ confirm: i18nKey("waitlist.action.reject.confirm"),
31
+ style: "danger",
32
+ visible: { field: "status", eq: WAITLIST_STATUS.Pending },
33
+ },
34
+ {
35
+ id: "reject-invited",
36
+ label: i18nKey("waitlist.action.reject"),
37
+ handler: WaitlistHandlers.reject,
38
+ confirm: i18nKey("waitlist.action.reject.confirm"),
39
+ style: "danger",
40
+ visible: { field: "status", eq: WAITLIST_STATUS.Invited },
41
+ },
42
+ ],
43
+ };
@@ -0,0 +1,82 @@
1
+ import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import type {
3
+ UserDataDeleteHook,
4
+ UserDataExportHook,
5
+ UserDataHookCtx,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { collectErasureFailure, decryptStoredPii, throwIfErasureFailed } from "../shared/index.js";
8
+ import { userTable } from "../user/index.js";
9
+ import { normalizeEmail, platformActor, waitlistExecutor } from "./lib.js";
10
+
11
+ const PII_DECRYPT_REASON = "waitlist:user-data";
12
+ const ERASE_REASON = "waitlist:user-data-forget";
13
+ const ENTRY_LOOKUP_LIMIT = 100;
14
+
15
+ // Entries are matched by email, so the user must have proven they own it:
16
+ // otherwise registering with someone else's address would expose or erase that
17
+ // person's entries. The forget anonymization leaves emailVerified untouched,
18
+ // so the live row stays a reliable source; only the email needs the pre-tx
19
+ // copy because the user hook has already rewritten it by now.
20
+ async function resolveVerifiedUserEmail(ctx: UserDataHookCtx): Promise<string | null> {
21
+ const row = await ctx.db.fetchOne<{ email: string; emailVerified: boolean }>(userTable, {
22
+ id: ctx.userId,
23
+ });
24
+ if (row?.emailVerified !== true) return null;
25
+ if (ctx.userEmailBeforeDelete) return normalizeEmail(ctx.userEmailBeforeDelete);
26
+ return normalizeEmail(await decryptStoredPii(row.email, "email", PII_DECRYPT_REASON));
27
+ }
28
+
29
+ // Entries are decrypted by the executor's list (subject-encrypted name/email/message).
30
+ async function findEntriesByEmail(ctx: UserDataHookCtx, email: string) {
31
+ const found = await waitlistExecutor.list(
32
+ { filter: { field: "email", op: "eq", value: email }, limit: ENTRY_LOOKUP_LIMIT },
33
+ platformActor(),
34
+ ctx.db,
35
+ );
36
+ return found.rows;
37
+ }
38
+
39
+ export const waitlistEntryExportHook: UserDataExportHook = async (ctx) => {
40
+ const email = await resolveVerifiedUserEmail(ctx);
41
+ // skip: unverified or missing email must not be matched against waitlist entries
42
+ if (!email) return null;
43
+ const rows = await findEntriesByEmail(ctx, email);
44
+ if (rows.length === 0) return null;
45
+ return {
46
+ entity: "waitlistEntry",
47
+ rows: rows.map((row) => ({
48
+ name: row["name"],
49
+ email: row["email"],
50
+ company: row["company"],
51
+ portfolio: row["portfolio"],
52
+ message: row["message"],
53
+ locale: row["locale"],
54
+ status: row["status"],
55
+ submittedAt: String(row["submittedAt"] ?? ""),
56
+ })),
57
+ };
58
+ };
59
+
60
+ // Waitlist entries carry no retention duty, so both strategies erase: the row
61
+ // is purged through the executor (replays on rebuild) and its record subject
62
+ // key is shredded so the event log's ciphertext becomes unreadable.
63
+ export const waitlistEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
64
+ const email = await resolveVerifiedUserEmail(ctx);
65
+ // skip: unverified or missing email must not be matched against waitlist entries
66
+ if (!email) return;
67
+ const kms = configuredPiiSubjectKms();
68
+ const failures: string[] = [];
69
+ for (const row of await findEntriesByEmail(ctx, email)) {
70
+ const id = row["id"];
71
+ if (typeof id !== "string") continue;
72
+ const forgotten = await waitlistExecutor.forget({ id }, platformActor(), ctx.db);
73
+ collectErasureFailure(forgotten, "waitlistEntry", id, failures);
74
+ if (forgotten.isSuccess && kms) {
75
+ await kms.eraseKey(
76
+ { kind: "record", entity: "waitlistEntry", id },
77
+ { requestId: ERASE_REASON, userId: ctx.userId, eraseReason: ERASE_REASON },
78
+ );
79
+ }
80
+ }
81
+ throwIfErasureFailed(failures);
82
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
3
- "featureCount": 80,
3
+ "featureCount": 81,
4
4
  "features": [
5
5
  {
6
6
  "name": "admin-shell",
@@ -55,14 +55,39 @@
55
55
  "toggleableDefault": null,
56
56
  "requires": [
57
57
  "tenant",
58
- "user"
58
+ "user",
59
+ "config"
59
60
  ],
60
61
  "optionalRequires": [],
61
62
  "configReads": [],
62
63
  "exposesApis": [],
63
64
  "usesApis": [],
64
65
  "extensionsUsed": [],
65
- "configKeys": [],
66
+ "configKeys": [
67
+ {
68
+ "key": "escape-hatch-retention-days",
69
+ "qualifiedName": "audit:config:escape-hatch-retention-days",
70
+ "type": "number",
71
+ "scope": "system",
72
+ "default": 90,
73
+ "encrypted": false,
74
+ "computed": false,
75
+ "validated": false,
76
+ "options": null,
77
+ "bounds": {
78
+ "min": 1
79
+ },
80
+ "pattern": null,
81
+ "writeRoles": [
82
+ "system"
83
+ ],
84
+ "readRoles": [
85
+ "TenantAdmin",
86
+ "Admin",
87
+ "SystemAdmin"
88
+ ]
89
+ }
90
+ ],
66
91
  "secrets": [],
67
92
  "writeHandlers": [],
68
93
  "uiHints": {
@@ -102,6 +127,7 @@
102
127
  "auth-email-password:write:reset-password",
103
128
  "auth-email-password:write:signup-confirm",
104
129
  "auth-email-password:write:signup-request",
130
+ "auth-email-password:write:system-invite-create",
105
131
  "auth-email-password:write:verify-email"
106
132
  ],
107
133
  "uiHints": {
@@ -193,9 +219,7 @@
193
219
  "bounds": null,
194
220
  "pattern": null,
195
221
  "writeRoles": [
196
- "TenantAdmin",
197
- "Admin",
198
- "SystemAdmin"
222
+ "system"
199
223
  ],
200
224
  "readRoles": [
201
225
  "all"
@@ -516,7 +540,7 @@
516
540
  },
517
541
  {
518
542
  "name": "channel-telegram",
519
- "description": "Sends delivery notifications through the Telegram Bot API (`sendMessage`, plain text), registered as the `telegram` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { telegram: \"<chat id or @channel>\" } })`. The bot token is the tenant secret `channel-telegram:secret:bot-token`; the token never reaches logs or delivery attempts. Requires `delivery` and `secrets`. The API base URL and request timeout are app options of `createChannelTelegramFeature(opts)`, never tenant config.",
543
+ "description": "Sends delivery notifications through the Telegram Bot API (`sendMessage`, plain text), registered as the `telegram` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { telegram: \"<chat id or @channel>\" } })`. The bot token is the tenant secret `channel-telegram:secret:bot-token`; the token never reaches logs or delivery attempts. Requires `delivery` and `secrets`. The API base URL, its host allowlist (`allowedHosts`, default `api.telegram.org`), `requireHttps` (default true; boot fails on a mismatch) and the request timeout are app options of `createChannelTelegramFeature(opts)`, never tenant config.",
520
544
  "toggleableDefault": null,
521
545
  "requires": [
522
546
  "delivery",
@@ -3053,6 +3077,38 @@
3053
3077
  "recommended": true
3054
3078
  }
3055
3079
  },
3080
+ {
3081
+ "name": "waitlist",
3082
+ "description": "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response never reveals whether an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
3083
+ "toggleableDefault": null,
3084
+ "requires": [
3085
+ "tenant",
3086
+ "auth-email-password",
3087
+ "user-data-rights"
3088
+ ],
3089
+ "optionalRequires": [],
3090
+ "configReads": [],
3091
+ "exposesApis": [],
3092
+ "usesApis": [],
3093
+ "extensionsUsed": [
3094
+ {
3095
+ "extensionName": "userData",
3096
+ "entityName": "waitlistEntry"
3097
+ }
3098
+ ],
3099
+ "configKeys": [],
3100
+ "secrets": [],
3101
+ "writeHandlers": [
3102
+ "waitlist:write:invite",
3103
+ "waitlist:write:reject",
3104
+ "waitlist:write:submit"
3105
+ ],
3106
+ "uiHints": {
3107
+ "displayLabel": "Waitlist",
3108
+ "category": "data",
3109
+ "recommended": false
3110
+ }
3111
+ },
3056
3112
  {
3057
3113
  "name": "workflow-runner",
3058
3114
  "description": "Writes the run-envelope (`workflow.run-started` / `workflow.run-completed` / `workflow.run-failed`) for a workflow run onto its `workflow-run` aggregate stream. Provides `startAndRunWorkflow` and `registerEventTrigger` for consumer features to wire up `defineWorkflow`-based workflows against domain events. Registers no workflows itself. Runs the resume loop for suspended `wait`/`retry`/`waitForEvent` steps (`resume-due-runs` job + `resume-run` handler); `registerEventTrigger` wires an event-wakeup subscriber per workflow that declares `awaits` so a `waitForEvent` step resumes as soon as its awaited event arrives, not just on timeout.",
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": 1,
3
+ "tables": ["read_waitlist_entries"]
4
+ }
@@ -284,6 +284,11 @@ const FEATURE_IMPORT_REGISTRY: Record<string, FeatureImport> = {
284
284
  path: "@cosmicdrift/kumiko-bundled-features/legal-pages",
285
285
  factory: "createLegalPagesFeature",
286
286
  },
287
+ waitlist: {
288
+ kind: "factory",
289
+ path: "@cosmicdrift/kumiko-bundled-features/waitlist",
290
+ factory: "createWaitlistFeature",
291
+ },
287
292
  "managed-pages": {
288
293
  kind: "factory",
289
294
  path: "@cosmicdrift/kumiko-bundled-features/managed-pages",
@@ -17,6 +17,7 @@ import { capCounterFeature } from "@cosmicdrift/kumiko-bundled-features/cap-coun
17
17
  import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
18
18
  import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features/data-retention";
19
19
  import { createUserDataRightsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
20
+ import { createWaitlistFeature } from "@cosmicdrift/kumiko-bundled-features/waitlist";
20
21
  import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
21
22
  import { createManagedPagesFeature } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
22
23
  import { customFieldsFeature } from "@cosmicdrift/kumiko-bundled-features/custom-fields";
@@ -39,6 +40,7 @@ const _capCounter = capCounterFeature;
39
40
  const _complianceProfiles = createComplianceProfilesFeature();
40
41
  const _dataRetention = createDataRetentionFeature();
41
42
  const _userDataRights = createUserDataRightsFeature();
43
+ const _waitlist = createWaitlistFeature();
42
44
  const _templateResolver = createTemplateResolverFeature();
43
45
  const _managedPages = createManagedPagesFeature({ resolveApexTenant: () => null, allowCustomCss: true });
44
46
  const _customFields = customFieldsFeature;
@@ -66,6 +68,7 @@ export const tenantRetentionOverrideTable = buildEntityTable("tenant-retention-o
66
68
  export const exportJobTable = buildEntityTable("export-job", _userDataRights.entities["export-job"]!);
67
69
  export const exportDownloadTokenTable = buildEntityTable("export-download-token", _userDataRights.entities["export-download-token"]!);
68
70
  export const downloadAttemptTable = buildEntityTable("download-attempt", _userDataRights.entities["download-attempt"]!);
71
+ export const waitlistEntryTable = buildEntityTable("waitlistEntry", _waitlist.entities["waitlistEntry"]!);
69
72
  export const templateResourceTable = buildEntityTable("template-resource", _templateResolver.entities["template-resource"]!);
70
73
  export const userContentEntryTable = buildEntityTable("user-content-entry", _templateResolver.entities["user-content-entry"]!);
71
74
  export const pageTable = buildEntityTable("page", _managedPages.entities["page"]!);
@@ -92,6 +92,7 @@ import { tierEngineFeature } from "@cosmicdrift/kumiko-bundled-features/tier-eng
92
92
  import { createUserDataRightsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
93
93
  import { createUserDataRightsDefaultsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights-defaults";
94
94
  import { createUserProfileFeature } from "@cosmicdrift/kumiko-bundled-features/user-profile";
95
+ import { createWaitlistFeature } from "@cosmicdrift/kumiko-bundled-features/waitlist";
95
96
  import { workflowRunnerFeature } from "@cosmicdrift/kumiko-bundled-features/workflow-runner";
96
97
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
97
98
  import { localeDe } from "@cosmicdrift/kumiko-locale-de";
@@ -288,6 +289,7 @@ export const APP_FEATURES = [
288
289
 
289
290
  // CMS / content
290
291
  createLegalPagesFeature(),
292
+ createWaitlistFeature(),
291
293
  createTemplateResolverFeature({
292
294
  // One collection per ownership mode so the smoke app exercises both paths:
293
295
  // a shared set an admin curates, and a per-user set every agent keeps.
@@ -10,6 +10,8 @@ Shows how a feature posts to a tenant's Slack channel without ever handling the
10
10
 
11
11
  **App-level hardening:** Each channel only talks to its provider's hosts, over https, without following redirects. `allowedHosts`, `requireHttps` and `timeoutMs` are options of `createChannelSlackFeature(opts)`. Tenants cannot change them; the test uses them to point at a local stub.
12
12
 
13
+ **Production boot:** `runProdApp`, `runDevApp` and `runWorkerApp` wire `ctx.notify` and the tenant secrets for you, so no app wiring is needed. Queued channels run through the `delivery.render`/`delivery.send` jobs on the worker lane (all-in-one by default); a "send test message" handler can pass `immediate: true` to `ctx.notify` to deliver inline and read `sent`/`failed` from the returned `deliveries`.
14
+
13
15
  The same shape works for `channel-discord`, `channel-teams` (connection name + webhook secret) and `channel-telegram` (one `botToken` secret, the address is the chat id).
14
16
 
15
17
  ## Run
@@ -74,7 +74,7 @@ await runProdApp({
74
74
  _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
75
75
  // Required whenever a key declares backing:"secrets" — without it the set
76
76
  // and read paths fail loud, never silently miss.
77
- secrets: createSecretsContext({ db, masterKeyProvider }),
77
+ secrets: createSecretsContext({ db, masterKeyProvider, registry }),
78
78
  }),
79
79
  // ...
80
80
  });
@@ -7,6 +7,27 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
7
7
  - Handler `rateLimit: { per, limit, windowSeconds }`
8
8
  - How L1/L2 from `buildServer` stack with L3
9
9
 
10
+ ## Per-payload limits
11
+
12
+ A public write handler that mails an address typed by the caller is also limited per
13
+ address, so rotating IPs does not help against mail flooding. Declare it next to `rateLimit`:
14
+
15
+ ```ts
16
+ r.writeHandler({
17
+ // ...
18
+ access: { roles: ["anonymous"] },
19
+ rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
20
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
21
+ });
22
+ ```
23
+
24
+ `additionalRateLimits` only complements `rateLimit`: an anonymous handler still needs a real,
25
+ ip-keyed `rateLimit` (boot fails otherwise), and `payloadField` must be a top-level string field
26
+ of the handler's Zod object schema. The value is trimmed, lowercased and HMAC-hashed into the
27
+ bucket key, so Redis never holds the address in plaintext. The check runs after schema
28
+ validation and before the handler, identically for matching and non-matching values, and is
29
+ skipped for system callers. Exceeding it answers 429 `rate_limited`.
30
+
10
31
  ## Source
11
32
 
12
33
  Feature entry point: `src/feature.ts`.