@cosmicdrift/kumiko-samples 0.339.0 → 0.341.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
  4. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  5. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  6. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  14. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  15. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  16. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  17. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  18. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  19. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  21. package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  28. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  29. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  30. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  31. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  32. package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
  33. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  34. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  35. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  36. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  37. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  38. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  39. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  40. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  41. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  42. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  43. package/packages/bundled-features/src/delivery/changes.json +7 -0
  44. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  45. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  46. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  47. package/packages/bundled-features/src/delivery/index.ts +4 -0
  48. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  49. package/packages/bundled-features/src/delivery/types.ts +10 -4
  50. package/packages/bundled-features/src/files/changes.json +13 -1
  51. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  52. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  53. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  54. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  55. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  56. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  57. package/packages/bundled-features/src/page-render/index.ts +2 -0
  58. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  59. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  60. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  61. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  62. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  63. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  64. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  65. package/packages/bundled-features/src/secrets/changes.json +12 -0
  66. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  67. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  68. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  69. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  70. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  71. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  72. package/packages/bundled-features/src/tenant/changes.json +6 -0
  73. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  74. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  75. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  76. package/packages/bundled-features/src/tenant/index.ts +1 -0
  77. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  78. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  79. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  80. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  81. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  82. package/packages/bundled-features/src/user/changes.json +13 -6
  83. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  84. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  85. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  86. package/packages/bundled-features/src/waitlist/changes.json +17 -0
  87. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  88. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  89. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  90. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  91. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  92. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  93. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  94. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  95. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  96. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  97. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  98. package/packages/bundled-features/src/waitlist/options.ts +41 -0
  99. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  100. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  101. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  102. package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
  103. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  104. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  105. package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
  106. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  107. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  108. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  109. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  110. package/samples/recipes/chat-channels/README.md +2 -0
  111. package/samples/recipes/managed-config/README.md +1 -1
  112. package/samples/recipes/rate-limiting/README.md +21 -0
  113. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  114. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  115. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -0,0 +1,41 @@
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
+ readonly notifyRecipient?: (ctx: HandlerContext) => string | null;
22
+ /** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
23
+ readonly invite?: WaitlistInviteOptions;
24
+ /** Default: 5 submits per IP per 10 minutes. */
25
+ readonly rateLimit?: RateLimitOption;
26
+ /** Default: 3 submits per email address per day, across all IPs. Stops a botnet
27
+ * from flooding one address with confirmation mails. Pass `[]` to disable. */
28
+ readonly emailRateLimits?: readonly PayloadRateLimitOption[];
29
+ /** Shown in the confirmation mail; omitted when unset. */
30
+ readonly appName?: string;
31
+ };
32
+
33
+ export const DEFAULT_SUBMIT_RATE_LIMIT: RateLimitOption = {
34
+ per: "ip+handler",
35
+ limit: 5,
36
+ windowSeconds: 10 * 60,
37
+ };
38
+
39
+ export const DEFAULT_SUBMIT_EMAIL_RATE_LIMITS: readonly PayloadRateLimitOption[] = [
40
+ { per: { payloadField: "email" }, limit: 3, windowSeconds: 24 * 60 * 60 },
41
+ ];
@@ -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,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.340.0",
4
+ "type": "improvement",
5
+ "title": "The resume job reads due runs through the job's TenantDb",
6
+ "detail": "`selectDueWorkflowRunPending` takes a `TenantDb` and returns `{ runId, stepIndex }` rows, so the tenant filter comes from the TenantDb instead of a hand-written `WHERE tenant_id`.",
7
+ "migration": "keine"
8
+ },
2
9
  {
3
10
  "version": "0.339.0",
4
11
  "type": "breaking",
@@ -1,8 +1,7 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { sql, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { workflowRunPendingTable } from "../../tables.js";
4
3
 
5
- export type DueRow = { readonly run_id: string; readonly step_index: number };
4
+ export type DueRow = { readonly runId: string; readonly stepIndex: number };
6
5
 
7
6
  // Oldest first and capped, so a backlog after an outage drains over several
8
7
  // ticks instead of holding one tick's `concurrency: "skip"` slot for minutes.
@@ -12,13 +11,11 @@ export const DUE_RUNS_BATCH_SIZE = 100;
12
11
  // (ctx.tryAppendEvent on WORKFLOW_RESUMED) — a second dispatch for the same
13
12
  // row loses that race and no-ops. No row locks: in an autocommit statement
14
13
  // they would be released right after the SELECT and guard nothing.
15
- export async function selectDueWorkflowRunPending(
16
- db: DbConnection,
17
- tenantId: TenantId,
18
- ): Promise<readonly DueRow[]> {
19
- // kumiko-lint-ignore raw-sql tenant-scoped due-row pickup
20
- return (await asRawClient(db).unsafe(
21
- `SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now() ORDER BY wake_at ASC LIMIT ${DUE_RUNS_BATCH_SIZE}`,
22
- [tenantId],
23
- )) as readonly DueRow[];
14
+ // `now()` stays the DB clock so job-host clock skew cannot mark rows due early.
15
+ export function selectDueWorkflowRunPending(db: TenantDb): Promise<readonly DueRow[]> {
16
+ return db.selectMany<DueRow>(
17
+ workflowRunPendingTable,
18
+ { wakeAt: { lt: sql`now()` } },
19
+ { orderBy: { col: "wakeAt", direction: "asc" }, limit: DUE_RUNS_BATCH_SIZE },
20
+ );
24
21
  }
@@ -24,7 +24,6 @@
24
24
  //
25
25
  // wait/retry/waitForEvent suspensions all resume automatically now.
26
26
 
27
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
28
27
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
29
28
  import { selectDueWorkflowRunPending } from "./db/queries/due-runs.js";
30
29
  import { resumeRunHandler } from "./handlers/resume-run.write.js";
@@ -68,26 +67,20 @@ export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
68
67
  return;
69
68
  }
70
69
 
71
- ctx.systemDb.assertTenantMatch(tenantId);
72
- const dueRows = await selectDueWorkflowRunPending(
73
- ctx.systemDb.unsafeRaw(
74
- "due-row pickup needs FOR UPDATE SKIP LOCKED raw SQL; the query filters by this job's tenantId",
75
- ) as DbConnection, // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
76
- tenantId,
77
- );
70
+ const dueRows = await selectDueWorkflowRunPending(ctx.systemDb.assertTenantMatch(tenantId));
78
71
 
79
72
  for (const row of dueRows) {
80
73
  try {
81
74
  await ctx.write("workflow-runner:write:resume-run", {
82
- runId: row.run_id,
83
- stepIndex: row.step_index,
75
+ runId: row.runId,
76
+ stepIndex: row.stepIndex,
84
77
  });
85
78
  } catch (error) {
86
79
  // skip: one row's dispatch failing (infra hiccup, not a business
87
80
  // outcome — resume-run itself never throws for expected cases)
88
81
  // must not stop the rest of this tenant's due rows from resuming.
89
82
  ctx.log.warn(
90
- `[workflow-runner:resume-due-runs] runId=${row.run_id} stepIndex=${row.step_index} dispatch failed: ${String(error)}`,
83
+ `[workflow-runner:resume-due-runs] runId=${row.runId} stepIndex=${row.stepIndex} dispatch failed: ${String(error)}`,
91
84
  );
92
85
  }
93
86
  }
@@ -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",
@@ -102,6 +102,7 @@
102
102
  "auth-email-password:write:reset-password",
103
103
  "auth-email-password:write:signup-confirm",
104
104
  "auth-email-password:write:signup-request",
105
+ "auth-email-password:write:system-invite-create",
105
106
  "auth-email-password:write:verify-email"
106
107
  ],
107
108
  "uiHints": {
@@ -193,9 +194,7 @@
193
194
  "bounds": null,
194
195
  "pattern": null,
195
196
  "writeRoles": [
196
- "TenantAdmin",
197
- "Admin",
198
- "SystemAdmin"
197
+ "system"
199
198
  ],
200
199
  "readRoles": [
201
200
  "all"
@@ -516,7 +515,7 @@
516
515
  },
517
516
  {
518
517
  "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.",
518
+ "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
519
  "toggleableDefault": null,
521
520
  "requires": [
522
521
  "delivery",
@@ -3053,6 +3052,38 @@
3053
3052
  "recommended": true
3054
3053
  }
3055
3054
  },
3055
+ {
3056
+ "name": "waitlist",
3057
+ "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.",
3058
+ "toggleableDefault": null,
3059
+ "requires": [
3060
+ "tenant",
3061
+ "auth-email-password",
3062
+ "user-data-rights"
3063
+ ],
3064
+ "optionalRequires": [],
3065
+ "configReads": [],
3066
+ "exposesApis": [],
3067
+ "usesApis": [],
3068
+ "extensionsUsed": [
3069
+ {
3070
+ "extensionName": "userData",
3071
+ "entityName": "waitlistEntry"
3072
+ }
3073
+ ],
3074
+ "configKeys": [],
3075
+ "secrets": [],
3076
+ "writeHandlers": [
3077
+ "waitlist:write:invite",
3078
+ "waitlist:write:reject",
3079
+ "waitlist:write:submit"
3080
+ ],
3081
+ "uiHints": {
3082
+ "displayLabel": "Waitlist",
3083
+ "category": "data",
3084
+ "recommended": false
3085
+ }
3086
+ },
3056
3087
  {
3057
3088
  "name": "workflow-runner",
3058
3089
  "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`.
@@ -1,19 +0,0 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
-
4
- // INSERT-once, not UPSERT: unlike subscriptionsProjectionTable (one row per
5
- // tenant, state overwritten on every event), a payment row is an immutable
6
- // fact keyed by paymentRowId(tenantId, providerName, providerEventId) — ON
7
- // CONFLICT DO NOTHING makes a projection rebuild/replay idempotent without
8
- // ever mutating an existing row.
9
- export async function insertPaymentProjectionRow(
10
- tx: DbRunner,
11
- tableName: string,
12
- insertCols: Record<string, unknown>,
13
- ): Promise<void> {
14
- const insertKeys = Object.keys(insertCols);
15
- const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
16
- const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO NOTHING`;
17
- // kumiko-lint-ignore raw-sql tableName + column keys are code-controlled, not user input: tableName is buildEntityTable()'s static table name and insertKeys come from applyPaymentReceived's hardcoded object-literal columns in projection.ts — only values flow through $1..$n
18
- await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
19
- }
@@ -1,15 +0,0 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
-
4
- export async function upsertSubscriptionProjectionRow(
5
- tx: DbRunner,
6
- tableName: string,
7
- insertCols: Record<string, unknown>,
8
- setClauses: readonly string[],
9
- params: readonly unknown[],
10
- ): Promise<void> {
11
- const insertKeys = Object.keys(insertCols);
12
- const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
13
- const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO UPDATE SET ${setClauses.join(", ")}`;
14
- await asRawClient(tx).unsafe(sqlText, params);
15
- }
@@ -1,38 +0,0 @@
1
- // Raw-SQL-Helper für die inline projections — Muster
2
- // billing-foundation/db/queries/subscription-projection.ts.
3
- //
4
- // Warum raw statt drizzle-insert: die apply-fns laufen in der event-TX
5
- // mit einem DbRunner, und die ON-CONFLICT-Semantik (UPSERT vs.
6
- // DO NOTHING) ist der fachliche Kern — explizit hingeschrieben statt
7
- // hinter einem ORM-Builder versteckt.
8
-
9
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
11
-
12
- /** INSERT ... ON CONFLICT ("id") DO UPDATE SET <setClauses>. */
13
- export async function upsertProjectionRow(
14
- tx: DbRunner,
15
- tableName: string,
16
- insertCols: Record<string, unknown>,
17
- setClauses: readonly string[],
18
- params: readonly unknown[],
19
- ): Promise<void> {
20
- const insertKeys = Object.keys(insertCols);
21
- const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
22
- const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO UPDATE SET ${setClauses.join(", ")}`;
23
- await asRawClient(tx).unsafe(sqlText, params);
24
- }
25
-
26
- /** INSERT ... ON CONFLICT ("id") DO NOTHING — für append-only-Rows
27
- * (inbound-message: genau EIN received-event pro Stream, Replays
28
- * no-op'en auf der PK). */
29
- export async function insertIgnoreProjectionRow(
30
- tx: DbRunner,
31
- tableName: string,
32
- insertCols: Record<string, unknown>,
33
- ): Promise<void> {
34
- const insertKeys = Object.keys(insertCols);
35
- const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
36
- const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO NOTHING`;
37
- await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
38
- }