@cosmicdrift/kumiko-samples 0.340.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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +17 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +41 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
|
@@ -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,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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
|
|
3
|
-
"featureCount":
|
|
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
|
-
"
|
|
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.",
|
|
@@ -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`.
|