@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,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineFeature,
|
|
3
|
+
EXT_USER_DATA,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
i18nKey,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { WAITLIST_FEATURE } from "./constants.js";
|
|
8
|
+
import { waitlistEntryEntity } from "./entity.js";
|
|
9
|
+
import { createInviteHandler } from "./handlers/invite.write.js";
|
|
10
|
+
import { listQuery } from "./handlers/list.query.js";
|
|
11
|
+
import { rejectHandler } from "./handlers/reject.write.js";
|
|
12
|
+
import { createSubmitHandler } from "./handlers/submit.write.js";
|
|
13
|
+
import { WAITLIST_I18N } from "./i18n.js";
|
|
14
|
+
import { adminAccess } from "./lib.js";
|
|
15
|
+
import type { WaitlistOptions } from "./options.js";
|
|
16
|
+
import { waitlistListScreen } from "./screens.js";
|
|
17
|
+
import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-hooks.js";
|
|
18
|
+
|
|
19
|
+
export function createWaitlistFeature(opts: WaitlistOptions = {}): FeatureDefinition {
|
|
20
|
+
return defineFeature(WAITLIST_FEATURE, (r) => {
|
|
21
|
+
r.describe(
|
|
22
|
+
"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.",
|
|
23
|
+
);
|
|
24
|
+
r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
|
|
25
|
+
r.systemScope();
|
|
26
|
+
r.requires("tenant", "auth-email-password", "user-data-rights");
|
|
27
|
+
r.translations({ keys: WAITLIST_I18N });
|
|
28
|
+
|
|
29
|
+
r.entity("waitlistEntry", waitlistEntryEntity);
|
|
30
|
+
r.writeHandler(createSubmitHandler(opts));
|
|
31
|
+
r.writeHandler(createInviteHandler(opts.invite));
|
|
32
|
+
r.writeHandler(rejectHandler);
|
|
33
|
+
r.queryHandler(listQuery);
|
|
34
|
+
|
|
35
|
+
r.screen(waitlistListScreen);
|
|
36
|
+
r.nav({
|
|
37
|
+
id: "waitlist",
|
|
38
|
+
label: i18nKey("waitlist.nav.waitlist"),
|
|
39
|
+
screen: "waitlist:screen:waitlist-list",
|
|
40
|
+
icon: "users",
|
|
41
|
+
order: 15,
|
|
42
|
+
access: adminAccess,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
r.useExtension(EXT_USER_DATA, "waitlistEntry", {
|
|
46
|
+
export: waitlistEntryExportHook,
|
|
47
|
+
delete: waitlistEntryDeleteHook,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
type HandlerContext,
|
|
6
|
+
parseTenantId,
|
|
7
|
+
type TenantId,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
InternalError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
UnprocessableError,
|
|
13
|
+
type WriteFailure,
|
|
14
|
+
writeFailure,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
16
|
+
import { Temporal } from "temporal-polyfill";
|
|
17
|
+
import { AuthErrors, AuthHandlers } from "../../auth-email-password/index.js";
|
|
18
|
+
import { TenantHandlers, tenantTable } from "../../tenant/index.js";
|
|
19
|
+
import { DEFAULT_OWN_TENANT_INVITE_ROLE, WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
|
|
20
|
+
import { isOpenWaitlistStatus } from "../entity.js";
|
|
21
|
+
import { adminAccess, platformActor, waitlistDb, waitlistExecutor } from "../lib.js";
|
|
22
|
+
import type { WaitlistInviteOptions } from "../options.js";
|
|
23
|
+
import { WaitlistIdSchema } from "../payloads.js";
|
|
24
|
+
|
|
25
|
+
export type WaitlistInviteData = {
|
|
26
|
+
readonly kind: "invited";
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly tenantId: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type InviteTarget = { readonly tenantId: TenantId; readonly role: string };
|
|
32
|
+
|
|
33
|
+
const DEFAULT_INVITE: WaitlistInviteOptions = {
|
|
34
|
+
mode: "own-tenant",
|
|
35
|
+
role: DEFAULT_OWN_TENANT_INVITE_ROLE,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Derived from the entry id so a retry finds the tenant a half-failed run created.
|
|
39
|
+
function tenantKeyForEntry(entryId: string): string {
|
|
40
|
+
return `waitlist-${entryId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type EntryRow = {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly version: number;
|
|
46
|
+
readonly status: unknown;
|
|
47
|
+
readonly email: string;
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly company: string | null;
|
|
50
|
+
readonly linkedTenantId: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function readEntryRow(row: Record<string, unknown>): EntryRow {
|
|
54
|
+
const { id, version, email, name, company, linkedTenantId } = row;
|
|
55
|
+
if (
|
|
56
|
+
typeof id !== "string" ||
|
|
57
|
+
typeof version !== "number" ||
|
|
58
|
+
typeof email !== "string" ||
|
|
59
|
+
typeof name !== "string"
|
|
60
|
+
) {
|
|
61
|
+
throw new InternalError({ message: "waitlist:invite: malformed waitlist row" });
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
version,
|
|
66
|
+
status: row["status"],
|
|
67
|
+
email,
|
|
68
|
+
name,
|
|
69
|
+
company: typeof company === "string" && company !== "" ? company : null,
|
|
70
|
+
linkedTenantId: typeof linkedTenantId === "string" ? linkedTenantId : null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isInviteAlreadyAccepted(details: unknown): boolean {
|
|
75
|
+
return (
|
|
76
|
+
typeof details === "object" &&
|
|
77
|
+
details !== null &&
|
|
78
|
+
"reason" in details &&
|
|
79
|
+
details.reason === AuthErrors.inviteAlreadyAccepted
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function ensureOwnTenant(
|
|
84
|
+
entry: EntryRow,
|
|
85
|
+
ctx: HandlerContext,
|
|
86
|
+
): Promise<TenantId | WriteFailure> {
|
|
87
|
+
const linked = entry.linkedTenantId === null ? null : parseTenantId(entry.linkedTenantId);
|
|
88
|
+
if (linked) return linked;
|
|
89
|
+
|
|
90
|
+
const key = tenantKeyForEntry(entry.id);
|
|
91
|
+
const existing = await fetchOne<{ id: string }>(waitlistDb(ctx), tenantTable, { key });
|
|
92
|
+
const existingId = existing ? parseTenantId(existing.id) : null;
|
|
93
|
+
if (existingId) return existingId;
|
|
94
|
+
|
|
95
|
+
const newTenantId = parseTenantId(crypto.randomUUID());
|
|
96
|
+
if (!newTenantId) throw new InternalError({ message: "waitlist:invite: tenant id mint failed" });
|
|
97
|
+
const created = await ctx.writeAs(createSystemUser(newTenantId), TenantHandlers.create, {
|
|
98
|
+
id: newTenantId,
|
|
99
|
+
key,
|
|
100
|
+
name: entry.company ?? entry.name,
|
|
101
|
+
});
|
|
102
|
+
return created.isSuccess ? newTenantId : created;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function resolveInviteTarget(
|
|
106
|
+
invite: WaitlistInviteOptions,
|
|
107
|
+
entry: EntryRow,
|
|
108
|
+
ctx: HandlerContext,
|
|
109
|
+
): Promise<InviteTarget | WriteFailure> {
|
|
110
|
+
if (invite.mode === "shared-tenant") return { tenantId: invite.tenantId, role: invite.role };
|
|
111
|
+
const tenantId = await ensureOwnTenant(entry, ctx);
|
|
112
|
+
if (typeof tenantId !== "string") return tenantId;
|
|
113
|
+
return { tenantId, role: invite.role ?? DEFAULT_OWN_TENANT_INVITE_ROLE };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createInviteHandler(invite: WaitlistInviteOptions = DEFAULT_INVITE) {
|
|
117
|
+
return defineWriteHandler<"invite", typeof WaitlistIdSchema, WaitlistInviteData>({
|
|
118
|
+
name: "invite",
|
|
119
|
+
schema: WaitlistIdSchema,
|
|
120
|
+
access: adminAccess,
|
|
121
|
+
description:
|
|
122
|
+
"Invites a pending or already-invited waitlist entry: creates its tenant (or uses the configured shared tenant), mails the entrant a fresh accept link with the configured role and marks the entry invited; rejected entries cannot be invited. The invitee never receives a global role.",
|
|
123
|
+
// Sends an invite link by email, which cannot be recalled.
|
|
124
|
+
agent: { risk: "high" },
|
|
125
|
+
handler: async (event, ctx) => {
|
|
126
|
+
const db = waitlistDb(ctx);
|
|
127
|
+
const row = await waitlistExecutor.detail({ id: event.payload.id }, platformActor(), db);
|
|
128
|
+
if (!row) return writeFailure(new NotFoundError("waitlistEntry", event.payload.id));
|
|
129
|
+
const entry = readEntryRow(row);
|
|
130
|
+
if (!isOpenWaitlistStatus(entry.status)) {
|
|
131
|
+
return writeFailure(new UnprocessableError(WaitlistErrors.notInvitable));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const target = await resolveInviteTarget(invite, entry, ctx);
|
|
135
|
+
if ("isSuccess" in target) return target;
|
|
136
|
+
|
|
137
|
+
// inviteCreate is admin-only (no system role); the system variant with an
|
|
138
|
+
// empty globalRoles list is the only in-process path, and a waitlist
|
|
139
|
+
// invite must never grant a global role.
|
|
140
|
+
const invited = await ctx.writeAs(
|
|
141
|
+
createSystemUser(target.tenantId),
|
|
142
|
+
AuthHandlers.systemInviteCreate,
|
|
143
|
+
{ email: entry.email.toLowerCase(), role: target.role, globalRoles: [] },
|
|
144
|
+
);
|
|
145
|
+
if (!invited.isSuccess) {
|
|
146
|
+
// Re-inviting a person who already accepted would reset their live invitation.
|
|
147
|
+
if (isInviteAlreadyAccepted(invited.error.details)) {
|
|
148
|
+
return writeFailure(new UnprocessableError(WaitlistErrors.notInvitable));
|
|
149
|
+
}
|
|
150
|
+
return invited;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const updated = await waitlistExecutor.update(
|
|
154
|
+
{
|
|
155
|
+
id: entry.id,
|
|
156
|
+
version: entry.version,
|
|
157
|
+
changes: {
|
|
158
|
+
status: WAITLIST_STATUS.Invited,
|
|
159
|
+
invitedAt: Temporal.Now.instant(),
|
|
160
|
+
invitedBy: event.user.id,
|
|
161
|
+
linkedTenantId: target.tenantId,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
platformActor(),
|
|
165
|
+
db,
|
|
166
|
+
);
|
|
167
|
+
if (!updated.isSuccess) return updated;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
isSuccess: true,
|
|
171
|
+
data: { kind: "invited", id: entry.id, tenantId: target.tenantId },
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineEntityListHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { waitlistEntryEntity } from "../entity.js";
|
|
3
|
+
import { adminAccess } from "../lib.js";
|
|
4
|
+
|
|
5
|
+
// Entity-convention list: the QN waitlist:query:waitlist-entry:list is what the
|
|
6
|
+
// entityList screen resolves; paging, search and sort come from the entity flags.
|
|
7
|
+
export const listQuery = defineEntityListHandler("waitlistEntry", waitlistEntryEntity, {
|
|
8
|
+
access: adminAccess,
|
|
9
|
+
agent: { risk: "high" },
|
|
10
|
+
description:
|
|
11
|
+
"Lists waitlist entries for the admin screen with paging, search over name, email and company, and sorting; carries the entrants' personal data.",
|
|
12
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createSystemUser,
|
|
5
|
+
defineWriteHandler,
|
|
6
|
+
type HandlerContext,
|
|
7
|
+
parseTenantId,
|
|
8
|
+
type TenantId,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import {
|
|
11
|
+
InternalError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
UnprocessableError,
|
|
14
|
+
type WriteFailure,
|
|
15
|
+
writeFailure,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
17
|
+
import {
|
|
18
|
+
cancelPendingInvitation,
|
|
19
|
+
INVITATION_STATUS,
|
|
20
|
+
tenantInvitationsTable,
|
|
21
|
+
tenantMembershipsTable,
|
|
22
|
+
} from "../../tenant/index.js";
|
|
23
|
+
import { userTable } from "../../user/index.js";
|
|
24
|
+
import { WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
|
|
25
|
+
import { isOpenWaitlistStatus } from "../entity.js";
|
|
26
|
+
import {
|
|
27
|
+
adminAccess,
|
|
28
|
+
normalizeEmail,
|
|
29
|
+
platformActor,
|
|
30
|
+
waitlistDb,
|
|
31
|
+
waitlistExecutor,
|
|
32
|
+
} from "../lib.js";
|
|
33
|
+
import { WaitlistIdSchema } from "../payloads.js";
|
|
34
|
+
|
|
35
|
+
export type WaitlistRejectData = { readonly kind: "rejected"; readonly id: string };
|
|
36
|
+
|
|
37
|
+
// An admin re-invite can flip an accepted invitation back to pending, so the
|
|
38
|
+
// invitation status alone cannot prove the person holds no access.
|
|
39
|
+
async function isTenantMember(db: TenantDb, email: string, tenantId: TenantId): Promise<boolean> {
|
|
40
|
+
const user = await fetchOne<{ id: string }>(db, userTable, {
|
|
41
|
+
email: normalizeEmail(email),
|
|
42
|
+
isDeleted: false,
|
|
43
|
+
});
|
|
44
|
+
if (!user) return false;
|
|
45
|
+
const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
|
|
46
|
+
return membership !== undefined && membership !== null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// An invited entry already has a live invitation link; rejecting must kill it,
|
|
50
|
+
// otherwise the rejected person can still sign up. Once accepted they hold
|
|
51
|
+
// access, which only tenant member removal may revoke.
|
|
52
|
+
async function revokeInvitation(
|
|
53
|
+
row: Record<string, unknown>,
|
|
54
|
+
ctx: HandlerContext,
|
|
55
|
+
): Promise<WriteFailure | undefined> {
|
|
56
|
+
const { status, linkedTenantId, email } = row;
|
|
57
|
+
if (status !== WAITLIST_STATUS.Invited) return undefined;
|
|
58
|
+
const tenantId = typeof linkedTenantId === "string" ? parseTenantId(linkedTenantId) : null;
|
|
59
|
+
if (!tenantId || typeof email !== "string") return undefined;
|
|
60
|
+
const db = waitlistDb(ctx);
|
|
61
|
+
const invitations = await selectMany(db, tenantInvitationsTable, {
|
|
62
|
+
tenantId,
|
|
63
|
+
email: normalizeEmail(email),
|
|
64
|
+
});
|
|
65
|
+
if (
|
|
66
|
+
invitations.some((invitation) => invitation["status"] === INVITATION_STATUS.accepted) ||
|
|
67
|
+
(await isTenantMember(db, email, tenantId))
|
|
68
|
+
) {
|
|
69
|
+
return writeFailure(new UnprocessableError(WaitlistErrors.notRejectable));
|
|
70
|
+
}
|
|
71
|
+
for (const invitation of invitations) {
|
|
72
|
+
if (invitation["status"] !== INVITATION_STATUS.pending) continue;
|
|
73
|
+
const id = invitation["id"];
|
|
74
|
+
const version = invitation["version"];
|
|
75
|
+
if (typeof id !== "string" || typeof version !== "number") {
|
|
76
|
+
throw new InternalError({ message: "waitlist:reject: malformed invitation row" });
|
|
77
|
+
}
|
|
78
|
+
const cancelled = await cancelPendingInvitation(
|
|
79
|
+
db,
|
|
80
|
+
{ id, version },
|
|
81
|
+
createSystemUser(tenantId),
|
|
82
|
+
ctx.redis,
|
|
83
|
+
);
|
|
84
|
+
if (cancelled) return cancelled;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const rejectHandler = defineWriteHandler<
|
|
90
|
+
"reject",
|
|
91
|
+
typeof WaitlistIdSchema,
|
|
92
|
+
WaitlistRejectData
|
|
93
|
+
>({
|
|
94
|
+
name: "reject",
|
|
95
|
+
schema: WaitlistIdSchema,
|
|
96
|
+
access: adminAccess,
|
|
97
|
+
description:
|
|
98
|
+
"Marks a pending or invited waitlist entry as rejected, used by admins to dismiss spam or noise; the row and its personal data stay until erased.",
|
|
99
|
+
handler: async (event, ctx) => {
|
|
100
|
+
const db = waitlistDb(ctx);
|
|
101
|
+
const row = await waitlistExecutor.detail({ id: event.payload.id }, platformActor(), db);
|
|
102
|
+
if (!row) return writeFailure(new NotFoundError("waitlistEntry", event.payload.id));
|
|
103
|
+
const version = row["version"];
|
|
104
|
+
if (typeof version !== "number") {
|
|
105
|
+
throw new InternalError({ message: "waitlist:reject: malformed waitlist row" });
|
|
106
|
+
}
|
|
107
|
+
if (!isOpenWaitlistStatus(row["status"])) {
|
|
108
|
+
return writeFailure(new UnprocessableError(WaitlistErrors.notRejectable));
|
|
109
|
+
}
|
|
110
|
+
const revoked = await revokeInvitation(row, ctx);
|
|
111
|
+
if (revoked) return revoked;
|
|
112
|
+
const updated = await waitlistExecutor.update(
|
|
113
|
+
{ id: event.payload.id, version, changes: { status: WAITLIST_STATUS.Rejected } },
|
|
114
|
+
platformActor(),
|
|
115
|
+
db,
|
|
116
|
+
);
|
|
117
|
+
if (!updated.isSuccess) return updated;
|
|
118
|
+
return { isSuccess: true, data: { kind: "rejected", id: event.payload.id } };
|
|
119
|
+
},
|
|
120
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
type HandlerContext,
|
|
6
|
+
type WriteResult,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { Temporal } from "temporal-polyfill";
|
|
10
|
+
import { WAITLIST_NOTIFICATION_TYPES, WAITLIST_STATUS } from "../constants.js";
|
|
11
|
+
import { normalizeEmail, platformActor, waitlistDb, waitlistExecutor } from "../lib.js";
|
|
12
|
+
import {
|
|
13
|
+
renderWaitlistAdminNoticeEmail,
|
|
14
|
+
renderWaitlistConfirmationEmail,
|
|
15
|
+
resolveWaitlistAdminMailLocale,
|
|
16
|
+
resolveWaitlistMailLocale,
|
|
17
|
+
} from "../mail.js";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_SUBMIT_EMAIL_RATE_LIMITS,
|
|
20
|
+
DEFAULT_SUBMIT_RATE_LIMIT,
|
|
21
|
+
type WaitlistOptions,
|
|
22
|
+
} from "../options.js";
|
|
23
|
+
import { type WaitlistSubmitInput, WaitlistSubmitSchema } from "../payloads.js";
|
|
24
|
+
|
|
25
|
+
// pg_advisory_xact_lock namespace (int4): 'wlst' as ASCII, disjoint from the
|
|
26
|
+
// framework's other fixed advisory-lock keys.
|
|
27
|
+
const SUBMIT_LOCK_NAMESPACE = 0x776c7374;
|
|
28
|
+
const SUBMIT_LOCK_REASON =
|
|
29
|
+
"takes the per-email advisory lock so parallel submits cannot both pass the duplicate check; TenantDb has no lock API";
|
|
30
|
+
|
|
31
|
+
// Logged-in callers carry no anonymous role, so every signed-in role must be listed.
|
|
32
|
+
const SUBMIT_ROLES = access.roles(
|
|
33
|
+
...access.anonymous,
|
|
34
|
+
...access.authenticated,
|
|
35
|
+
"Member",
|
|
36
|
+
"TenantAdmin",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export type WaitlistSubmitData = { readonly kind: "submitted" };
|
|
40
|
+
|
|
41
|
+
// Identical for new, duplicate and honeypot submits: the response must not
|
|
42
|
+
// reveal whether the email is already on the list.
|
|
43
|
+
const SUBMITTED: WriteResult<WaitlistSubmitData> = {
|
|
44
|
+
isSuccess: true,
|
|
45
|
+
data: { kind: "submitted" },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// An explicitly bound `undefined` is an error for the postgres driver; a missing key is not.
|
|
49
|
+
function presentOptionalFields(payload: WaitlistSubmitInput) {
|
|
50
|
+
return {
|
|
51
|
+
...(payload.company !== undefined && { company: payload.company }),
|
|
52
|
+
...(payload.portfolio !== undefined && { portfolio: payload.portfolio }),
|
|
53
|
+
...(payload.message !== undefined && { message: payload.message }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isHoneypotFilled(payload: WaitlistSubmitInput): boolean {
|
|
58
|
+
return payload.website !== undefined && payload.website.trim() !== "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise<boolean> {
|
|
62
|
+
const found = await waitlistExecutor.list(
|
|
63
|
+
{ filter: { field: "email", op: "eq", value: email }, limit: 50 },
|
|
64
|
+
platformActor(),
|
|
65
|
+
waitlistDb(ctx),
|
|
66
|
+
);
|
|
67
|
+
return found.rows.some(
|
|
68
|
+
(row) => row["status"] === WAITLIST_STATUS.Pending || row["status"] === WAITLIST_STATUS.Invited,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function sendBestEffort(
|
|
73
|
+
ctx: HandlerContext,
|
|
74
|
+
label: string,
|
|
75
|
+
send: (notify: NonNullable<HandlerContext["notify"]>) => Promise<unknown>,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
if (!ctx.notify) {
|
|
79
|
+
throw new InternalError({ message: "ctx.notify unavailable — mount the delivery feature" });
|
|
80
|
+
}
|
|
81
|
+
await send(ctx.notify);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
ctx.log?.warn(`[waitlist:submit] ${label} mail failed`, {
|
|
84
|
+
error: err instanceof Error ? err.message : String(err),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createSubmitHandler(opts: WaitlistOptions) {
|
|
90
|
+
return defineWriteHandler<"submit", typeof WaitlistSubmitSchema, WaitlistSubmitData>({
|
|
91
|
+
name: "submit",
|
|
92
|
+
schema: WaitlistSubmitSchema,
|
|
93
|
+
access: { roles: SUBMIT_ROLES, personalData: "public-intake" },
|
|
94
|
+
rateLimit: opts.rateLimit ?? DEFAULT_SUBMIT_RATE_LIMIT,
|
|
95
|
+
additionalRateLimits: opts.emailRateLimits ?? DEFAULT_SUBMIT_EMAIL_RATE_LIMITS,
|
|
96
|
+
description:
|
|
97
|
+
"Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail. Always reports success, whether the address is new or already on the list.",
|
|
98
|
+
escapeHatch: { grants: ["unsafeRaw"], reason: SUBMIT_LOCK_REASON },
|
|
99
|
+
handler: async (event, ctx) => {
|
|
100
|
+
const payload = event.payload;
|
|
101
|
+
if (isHoneypotFilled(payload)) return SUBMITTED;
|
|
102
|
+
|
|
103
|
+
const email = normalizeEmail(payload.email);
|
|
104
|
+
if (!ctx.systemDb) {
|
|
105
|
+
throw new InternalError({
|
|
106
|
+
message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
await acquireNamespacedAdvisoryLock(
|
|
110
|
+
ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON),
|
|
111
|
+
SUBMIT_LOCK_NAMESPACE,
|
|
112
|
+
email,
|
|
113
|
+
);
|
|
114
|
+
if (await hasOpenEntryForEmail(email, ctx)) return SUBMITTED;
|
|
115
|
+
|
|
116
|
+
const created = await waitlistExecutor.create(
|
|
117
|
+
{
|
|
118
|
+
name: payload.name,
|
|
119
|
+
email,
|
|
120
|
+
locale: payload.locale,
|
|
121
|
+
...presentOptionalFields(payload),
|
|
122
|
+
status: WAITLIST_STATUS.Pending,
|
|
123
|
+
submittedAt: Temporal.Now.instant(),
|
|
124
|
+
},
|
|
125
|
+
platformActor(),
|
|
126
|
+
waitlistDb(ctx),
|
|
127
|
+
);
|
|
128
|
+
if (!created.isSuccess) return created;
|
|
129
|
+
|
|
130
|
+
await sendBestEffort(ctx, "confirmation", (notify) =>
|
|
131
|
+
notify(WAITLIST_NOTIFICATION_TYPES.confirmation, {
|
|
132
|
+
route: { email },
|
|
133
|
+
data: renderWaitlistConfirmationEmail({
|
|
134
|
+
locale: payload.locale,
|
|
135
|
+
appName: opts.appName,
|
|
136
|
+
}),
|
|
137
|
+
priority: "normal",
|
|
138
|
+
locale: resolveWaitlistMailLocale(payload.locale),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await sendBestEffort(ctx, "admin notice", async (notify) => {
|
|
143
|
+
const adminRecipient = opts.notifyRecipient?.(ctx);
|
|
144
|
+
// skip: no admin recipient configured
|
|
145
|
+
if (!adminRecipient) return;
|
|
146
|
+
await notify(WAITLIST_NOTIFICATION_TYPES.adminNotice, {
|
|
147
|
+
route: { email: adminRecipient },
|
|
148
|
+
data: renderWaitlistAdminNoticeEmail({
|
|
149
|
+
name: payload.name,
|
|
150
|
+
email,
|
|
151
|
+
company: payload.company,
|
|
152
|
+
message: payload.message,
|
|
153
|
+
adminLocale: ctx.locale,
|
|
154
|
+
}),
|
|
155
|
+
priority: "normal",
|
|
156
|
+
locale: resolveWaitlistAdminMailLocale(ctx.locale),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return SUBMITTED;
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
type LocalizedString = { readonly en: string; readonly de: string };
|
|
2
|
+
|
|
3
|
+
export const WAITLIST_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:waitlist-list.title": { en: "Waitlist", de: "Warteliste" },
|
|
5
|
+
"waitlist.nav.waitlist": { en: "Waitlist", de: "Warteliste" },
|
|
6
|
+
"waitlist:entity:waitlistEntry:field:name": { en: "Name", de: "Name" },
|
|
7
|
+
"waitlist:entity:waitlistEntry:field:email": { en: "Email", de: "E-Mail" },
|
|
8
|
+
"waitlist:entity:waitlistEntry:field:company": { en: "Company", de: "Firma" },
|
|
9
|
+
"waitlist:entity:waitlistEntry:field:portfolio": { en: "Portfolio", de: "Portfolio" },
|
|
10
|
+
"waitlist:entity:waitlistEntry:field:message": { en: "Message", de: "Nachricht" },
|
|
11
|
+
"waitlist:entity:waitlistEntry:field:locale": { en: "Locale", de: "Sprache" },
|
|
12
|
+
"waitlist:entity:waitlistEntry:field:status": { en: "Status", de: "Status" },
|
|
13
|
+
"waitlist:entity:waitlistEntry:field:submittedAt": { en: "Created", de: "Erstellt" },
|
|
14
|
+
"waitlist:entity:waitlistEntry:field:status:option:pending": {
|
|
15
|
+
en: "Pending",
|
|
16
|
+
de: "Ausstehend",
|
|
17
|
+
},
|
|
18
|
+
"waitlist:entity:waitlistEntry:field:status:option:invited": {
|
|
19
|
+
en: "Invited",
|
|
20
|
+
de: "Eingeladen",
|
|
21
|
+
},
|
|
22
|
+
"waitlist:entity:waitlistEntry:field:status:option:rejected": {
|
|
23
|
+
en: "Rejected",
|
|
24
|
+
de: "Abgelehnt",
|
|
25
|
+
},
|
|
26
|
+
"waitlist.action.invite": { en: "Invite", de: "Einladen" },
|
|
27
|
+
"waitlist.action.reInvite": { en: "Re-invite", de: "Erneut einladen" },
|
|
28
|
+
"waitlist.action.reject": { en: "Reject", de: "Ablehnen" },
|
|
29
|
+
"waitlist.action.reject.confirm": {
|
|
30
|
+
en: "Reject this entry? The data stays stored.",
|
|
31
|
+
de: "Eintrag ablehnen? Die Daten bleiben gespeichert.",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -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
|
+
}
|