@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +18 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
- package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +19 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +31 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +149 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +18 -2
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
- package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +18 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -4,7 +4,7 @@ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework
|
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { requireForTenant } from "../../shared/index.js";
|
|
6
6
|
import { tierAssignmentAggregateId } from "../aggregate-id.js";
|
|
7
|
-
import { TierAssignmentSources } from "../constants.js";
|
|
7
|
+
import { type TierAssignmentSource, TierAssignmentSources } from "../constants.js";
|
|
8
8
|
import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
|
|
9
9
|
|
|
10
10
|
// SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
|
|
@@ -14,7 +14,8 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
|
|
|
14
14
|
// Cross-tenant mechanics: ctx.forTenant(tenantId) yields a tenant-mode db bound to the target plus
|
|
15
15
|
// the streamTenantId, so the event lands in the target's stream with the operator as actor.
|
|
16
16
|
//
|
|
17
|
-
// `source
|
|
17
|
+
// `source` defaults to manual and marks the grant so the billing sync skips it; passing
|
|
18
|
+
// `billing` hands the row back to the sync, which overwrites it on the next webhook.
|
|
18
19
|
// Upsert: one aggregate per tenant (deterministic aggregate id).
|
|
19
20
|
//
|
|
20
21
|
// Effective-set invalidation: the executor write does not fire the
|
|
@@ -47,7 +48,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
47
48
|
return defineWriteHandler({
|
|
48
49
|
name: "set-tenant-tier",
|
|
49
50
|
description:
|
|
50
|
-
"Assigns a tier to any tenant as a manual grant without a billing purchase, marking it so a later billing sync will not overwrite it, and applies the new feature set immediately.",
|
|
51
|
+
"Assigns a tier to any tenant as a manual grant without a billing purchase, marking it so a later billing sync will not overwrite it (or, with source billing, handing the row back to the sync), and applies the new feature set immediately.",
|
|
51
52
|
schema: z.object({
|
|
52
53
|
tenantId: z.string().min(1),
|
|
53
54
|
tier: z
|
|
@@ -55,6 +56,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
55
56
|
.min(1)
|
|
56
57
|
.max(50)
|
|
57
58
|
.refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
|
|
59
|
+
source: z.enum(TierAssignmentSources).default(TierAssignmentSources.manual),
|
|
58
60
|
}),
|
|
59
61
|
access: { roles: ["SystemAdmin"] },
|
|
60
62
|
agent: { risk: "high" },
|
|
@@ -68,6 +70,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
68
70
|
const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
|
|
69
71
|
const { db: tdb, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
70
72
|
const tier = event.payload.tier;
|
|
73
|
+
const source: TierAssignmentSource = event.payload.source;
|
|
71
74
|
|
|
72
75
|
const existing = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
|
|
73
76
|
|
|
@@ -76,7 +79,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
76
79
|
{
|
|
77
80
|
id: existing.id,
|
|
78
81
|
version: existing.version,
|
|
79
|
-
changes: { tier, source
|
|
82
|
+
changes: { tier, source },
|
|
80
83
|
},
|
|
81
84
|
event.user,
|
|
82
85
|
tdb,
|
|
@@ -91,7 +94,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
91
94
|
{
|
|
92
95
|
id: tierAssignmentAggregateId(tenantId),
|
|
93
96
|
tier,
|
|
94
|
-
source
|
|
97
|
+
source,
|
|
95
98
|
tenantId,
|
|
96
99
|
},
|
|
97
100
|
event.user,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "resolveUserDisplayNames no longer swallows decrypt errors",
|
|
6
|
+
"detail": "A misconfigured or unavailable KMS made the delivery log silently show addresses or ids instead of names. Decrypt errors now propagate like in the member directory; users that are missing or have no display name are still left out."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.361.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -6,6 +6,7 @@ export const USER_FEATURE = "user" as const;
|
|
|
6
6
|
* platform operator. Membership roles (TenantAdmin/Admin/User/…) live on
|
|
7
7
|
* tenant-membership, not here. */
|
|
8
8
|
export const GLOBAL_ROLE_OPTIONS = ["SystemAdmin"] as const;
|
|
9
|
+
export type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
9
10
|
|
|
10
11
|
// Qualified write handler names. Handlers carry the "user:" entity prefix so
|
|
11
12
|
// field-level access rules (passwordHash system-only etc.) are wired up.
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
9
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
|
-
import { GLOBAL_ROLE_OPTIONS } from "./constants.js";
|
|
10
|
+
import { GLOBAL_ROLE_OPTIONS, type GlobalRole } from "./constants.js";
|
|
11
11
|
import { userEntity, userTable } from "./schema/user.js";
|
|
12
12
|
|
|
13
13
|
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
@@ -18,8 +18,6 @@ type UserRolesRow = {
|
|
|
18
18
|
readonly roles: readonly string[] | string | null;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
22
|
-
|
|
23
21
|
function isGlobalRole(role: string): role is GlobalRole {
|
|
24
22
|
return GLOBAL_ROLE_OPTIONS.some((option) => option === role);
|
|
25
23
|
}
|
|
@@ -34,7 +32,7 @@ export function parseInvitedGlobalRoles(raw: unknown): readonly GlobalRole[] {
|
|
|
34
32
|
// already holds. Users live on SYSTEM_TENANT_ID (tenancy "global").
|
|
35
33
|
export async function grantInvitedGlobalRoles(
|
|
36
34
|
db: DbRunner,
|
|
37
|
-
options: { readonly userId: string; readonly globalRoles: readonly
|
|
35
|
+
options: { readonly userId: string; readonly globalRoles: readonly GlobalRole[] },
|
|
38
36
|
) {
|
|
39
37
|
const existing = await fetchOne<UserRolesRow>(db, userTable, { id: options.userId });
|
|
40
38
|
if (!existing) return writeFailure(new NotFoundError("user", options.userId));
|
|
@@ -6,9 +6,10 @@ import { userTable } from "./schema/user.js";
|
|
|
6
6
|
// Shares the KMS adapter's small dedicated pool, same as member-directory.
|
|
7
7
|
const KMS_POOL_CONCURRENCY = 4;
|
|
8
8
|
|
|
9
|
-
// One batched lookup for a page of user ids. Users that are missing
|
|
10
|
-
// display name,
|
|
11
|
-
//
|
|
9
|
+
// One batched lookup for a page of user ids. Users that are missing or have no
|
|
10
|
+
// display name are left out, so callers fall back to the raw id. Decrypt errors
|
|
11
|
+
// propagate (a misconfigured or unavailable KMS fails loudly, like member-directory);
|
|
12
|
+
// crypto-shredding is handled by the KMS layer.
|
|
12
13
|
export async function resolveUserDisplayNames(
|
|
13
14
|
db: TenantDb,
|
|
14
15
|
userIds: readonly string[],
|
|
@@ -18,18 +19,10 @@ export async function resolveUserDisplayNames(
|
|
|
18
19
|
const users = await selectMany(db, userTable, { id: [...userIds] });
|
|
19
20
|
const named = users.filter((user) => typeof user.displayName === "string");
|
|
20
21
|
await mapWithConcurrency(named, KMS_POOL_CONCURRENCY, async (user) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
String(user.displayName),
|
|
26
|
-
"displayName",
|
|
27
|
-
"user:resolve-display-names",
|
|
28
|
-
),
|
|
29
|
-
);
|
|
30
|
-
} catch {
|
|
31
|
-
// Unreadable name: the caller's fallback applies.
|
|
32
|
-
}
|
|
22
|
+
names.set(
|
|
23
|
+
String(user.id),
|
|
24
|
+
await decryptStoredPii(String(user.displayName), "displayName", "user:resolve-display-names"),
|
|
25
|
+
);
|
|
33
26
|
});
|
|
34
27
|
return names;
|
|
35
28
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// das Event UND schreibt die Projection-Zeile in einer TX.
|
|
12
12
|
|
|
13
13
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "@cosmicdrift/kumiko-framework/crypto";
|
|
14
15
|
import {
|
|
15
16
|
createEventStoreExecutor,
|
|
16
17
|
createTenantDb,
|
|
@@ -139,12 +140,25 @@ export async function rewriteSeededUserEmail(
|
|
|
139
140
|
if (typeof id !== "string") {
|
|
140
141
|
throw new Error("rewriteSeededUserEmail: existing user row has no string id");
|
|
141
142
|
}
|
|
143
|
+
const version = existingRow["version"];
|
|
144
|
+
if (typeof version !== "number") {
|
|
145
|
+
throw new Error("rewriteSeededUserEmail: existing user row has no numeric version");
|
|
146
|
+
}
|
|
147
|
+
// Every executor.update appends a user:updated event; skip when the stored
|
|
148
|
+
// blind index already matches so repeated boots do not grow the stream.
|
|
149
|
+
// Queried by the bidx column itself: fetchOne does not return it, and an
|
|
150
|
+
// `email` condition would also match a plaintext row with a blank index.
|
|
151
|
+
const blindIndexKey = configuredBlindIndexKey();
|
|
152
|
+
if (blindIndexKey !== undefined) {
|
|
153
|
+
const indexed = await fetchOne(db, userTable, {
|
|
154
|
+
id,
|
|
155
|
+
emailBidx: computeBlindIndex(blindIndexKey, email),
|
|
156
|
+
});
|
|
157
|
+
// skip: the stored blind index already matches, a rewrite would only append a no-op event.
|
|
158
|
+
if (indexed) return;
|
|
159
|
+
}
|
|
142
160
|
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
143
|
-
const result = await userExecutor.update(
|
|
144
|
-
{ id, version: existingRow["version"] as number, changes: { email } },
|
|
145
|
-
by,
|
|
146
|
-
tdb,
|
|
147
|
-
);
|
|
161
|
+
const result = await userExecutor.update({ id, version, changes: { email } }, by, tdb);
|
|
148
162
|
// version_conflict: a concurrent write already changed the row — fine for a seed helper.
|
|
149
163
|
// unique_violation: a duplicate row already holds the blind index; the repair
|
|
150
164
|
// has to wait for a manual duplicate cleanup.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "GDPR mails carry designer labels",
|
|
6
|
+
"detail": "Feature and notify labels for export-ready, export-failed, deletion-requested and deletion-executed name the GDPR mails in the document designer."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.364.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Regression tests prove that forget also shreds the previous email in user.updated events",
|
|
12
|
+
"detail": "No runtime change. Integration tests with a configured subject KMS show that after an email change followed by forget, the old and new email in user.updated changes and previous decrypt only to the erased sentinel and the subject key is gone. Without a configured KMS, event payloads stay plaintext by design."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.363.0",
|
|
4
16
|
"type": "breaking",
|
|
@@ -36,7 +36,6 @@ export const GDPR_MAIL_EN: Readonly<Record<string, string>> = {
|
|
|
36
36
|
"gdpr.mail.deletionExecuted.subject": "{app} — Your account has been deleted",
|
|
37
37
|
"gdpr.mail.deletionExecuted.intro":
|
|
38
38
|
"your {app} account and the associated personal data were deleted on {when}. This action is permanent.",
|
|
39
|
-
"gdpr.mail.fallbackUrl": "If the button doesn't work, copy this link into your browser:",
|
|
40
39
|
};
|
|
41
40
|
|
|
42
41
|
registerMailTranslations("en", GDPR_MAIL_EN);
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
type LocalizedString = { readonly en: string };
|
|
5
5
|
|
|
6
6
|
export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
|
+
"user-data-rights:feature.label": { en: "Privacy" },
|
|
8
|
+
"user-data-rights:notify.export-ready.label": { en: "Data export ready" },
|
|
9
|
+
"user-data-rights:notify.export-failed.label": { en: "Data export failed" },
|
|
10
|
+
"user-data-rights:notify.deletion-requested.label": { en: "Deletion requested" },
|
|
11
|
+
"user-data-rights:notify.deletion-executed.label": { en: "Deletion completed" },
|
|
7
12
|
"user-data-rights:nav.exportJobs": { en: "GDPR exports" },
|
|
8
13
|
"screen:export-job-list.title": { en: "GDPR exports" },
|
|
9
14
|
"screen:export-job-detail.title": { en: "Export job" },
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createEventStoreExecutor,
|
|
5
|
+
createTenantDb,
|
|
6
|
+
type WritePurpose,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
4
8
|
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
9
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
10
|
import { USER_STATUS, userEntity, userTable } from "../../user/index.js";
|
|
@@ -27,6 +31,9 @@ export type UpdateUserLifecycleOptions = {
|
|
|
27
31
|
// executor's `expect:`. Omitted: unconditional overwrite (skipOptimisticLock).
|
|
28
32
|
// Scalars only, see the executor's `expect:` doc.
|
|
29
33
|
readonly expect?: Readonly<Record<string, string | number | boolean | null>>;
|
|
34
|
+
// Erasure flows run after the subject key is erased; the write then stores the
|
|
35
|
+
// erased sentinel instead of failing on re-encrypt.
|
|
36
|
+
readonly writePurpose?: WritePurpose;
|
|
30
37
|
};
|
|
31
38
|
|
|
32
39
|
// `conn` is ctx.db.unsafeRaw() (regular handlers) or the open tx
|
|
@@ -59,7 +66,11 @@ export async function updateUserLifecycle(
|
|
|
59
66
|
{ id: userId, changes },
|
|
60
67
|
createSystemUser(SYSTEM_TENANT_ID),
|
|
61
68
|
tenantDb,
|
|
62
|
-
{
|
|
69
|
+
{
|
|
70
|
+
skipOptimisticLock: true,
|
|
71
|
+
...(options?.expect && { expect: options.expect }),
|
|
72
|
+
...(options?.writePurpose && { writePurpose: options.writePurpose }),
|
|
73
|
+
},
|
|
63
74
|
);
|
|
64
75
|
|
|
65
76
|
if (result.isSuccess) return { applied: true };
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
entityEventName,
|
|
48
48
|
executeRawQuery,
|
|
49
49
|
nullBlindIndexesForSubject,
|
|
50
|
+
WritePurposes,
|
|
50
51
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
51
52
|
import {
|
|
52
53
|
type EscapeHatchAuditSink,
|
|
@@ -531,7 +532,12 @@ async function processUser(args: {
|
|
|
531
532
|
// geworfen hat, kommen wir hier nicht an — die Tx rollback'd
|
|
532
533
|
// alles, der User bleibt im DeletionRequested-Status, naechster
|
|
533
534
|
// Run retried.
|
|
534
|
-
await updateUserLifecycle(
|
|
535
|
+
await updateUserLifecycle(
|
|
536
|
+
tx,
|
|
537
|
+
userId,
|
|
538
|
+
{ status: USER_STATUS.Deleted },
|
|
539
|
+
{ writePurpose: WritePurposes.erasure },
|
|
540
|
+
);
|
|
535
541
|
txSucceeded = true;
|
|
536
542
|
});
|
|
537
543
|
} catch (e) {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"type": "breaking",
|
|
17
17
|
"title": "user-profile now registers its own profile screen (id user-profile:screen:profile); app-side profile screen registrations must be removed (fw#2312).",
|
|
18
18
|
"detail": "`profile` is now a declarative `projectionDetail` screen bound to `user:query:user:me` instead of a custom React component: change-password and change-email stay `EditExtensionSection` components (re-auth flows a declarative action can't express), account deletion (request/cancel via `user-data-rights`, grace period) is now fully declarative fields + `actions: RowAction[]`, preserving the original confirm dialog and `visible: {field, eq/ne}` toggle 1:1. `userProfileClient()` now registers the two extension-section components via `extensionSectionComponents` instead of exposing a `ProfileScreen` component for apps to place in a `components` map. `ProfileScreen` is a dead export. New exports: `ChangeEmailSection`, `ChangePasswordSection` (the two surviving extension components, importable for tests but not meant to be placed manually). Known UI regression, accepted for this pass (same trade-off as `privacy-center`): the deletion confirm-dialog title is now always the action's `label`; the dynamically composed grace-period banner sentence is gone, replaced by a plain `gracePeriodEnd` date field; there is no cancel-deletion success toast.",
|
|
19
|
-
"migration": "The bundled feature now registers the `profile` screen itself (id `user-profile:screen:profile`) — an app's own `profile` custom-screen registration collides on boot with a duplicate short-id error. Delete the app-side registration entirely: `r.screen({ id: \"profile\", type: \"custom\", renderer: { react: { __component: \"UserProfileScreen\" } }, access: { openToAll: true } })` has no replacement to write, just remove the call. If you have an `r.nav({ screen: \"profile\" })` (or any other reference to the app-local `profile` screen id), repoint it at `\"user-profile:screen:profile\"`. Also remove `components: { UserProfileScreen: ProfileScreen }` from your renderer/client setup and the now-unused `ProfileScreen` import — the client no longer exposes that component. Known affected consumers (not modified here, out of this PR's scope): `money-horse`
|
|
19
|
+
"migration": "The bundled feature now registers the `profile` screen itself (id `user-profile:screen:profile`) — an app's own `profile` custom-screen registration collides on boot with a duplicate short-id error. Delete the app-side registration entirely: `r.screen({ id: \"profile\", type: \"custom\", renderer: { react: { __component: \"UserProfileScreen\" } }, access: { openToAll: true } })` has no replacement to write, just remove the call. If you have an `r.nav({ screen: \"profile\" })` (or any other reference to the app-local `profile` screen id), repoint it at `\"user-profile:screen:profile\"`. Also remove `components: { UserProfileScreen: ProfileScreen }` from your renderer/client setup and the now-unused `ProfileScreen` import — the client no longer exposes that component. Known affected consumers (not modified here, out of this PR's scope): `money-horse` and `offlot-app` both currently register their own `profile` custom screen this way."
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"version": "0.123.2",
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Waitlist mails carry designer labels",
|
|
6
|
+
"detail": "Feature and notify labels for confirmation and admin-notice name the waitlist mails in the document designer."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Waitlist invite no longer double-invites, uses the entry locale and refuses members",
|
|
12
|
+
"detail": "Two parallel invite calls for one entry now queue on a per-entry advisory lock, so exactly one tenant and one invitation are created. The invitation mail is rendered in the entry's locale instead of the caller's. Inviting an email that already is a member of the target tenant fails with 422 waitlist_not_invitable, as reject already did."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.365.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Waitlist submit honors emailRateLimits: [], resolves notifyRecipient before the advisory lock and checks only open entries",
|
|
18
|
+
"detail": "createWaitlistFeature({ emailRateLimits: [] }) no longer fails boot validation; the per-email bucket is dropped as documented. notifyRecipient runs before the per-email advisory lock and the entry insert, so a failing resolver can no longer abort the submit transaction or hold the lock; a throwing resolver only skips the admin notice. The duplicate check queries status pending or invited with limit 1 instead of scanning the first 50 rows of the address. The feature description now states that the response body is identical for known and unknown addresses while response timing can differ."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.363.0",
|
|
4
22
|
"type": "breaking",
|
|
@@ -26,7 +26,7 @@ import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-ho
|
|
|
26
26
|
export function createWaitlistFeature(opts: WaitlistOptions = {}): FeatureDefinition {
|
|
27
27
|
return defineFeature(WAITLIST_FEATURE, (r) => {
|
|
28
28
|
r.describe(
|
|
29
|
-
"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
|
|
29
|
+
"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 body is identical whether or not 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.",
|
|
30
30
|
);
|
|
31
31
|
r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
|
|
32
32
|
r.systemScope();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import {
|
|
3
4
|
createSystemUser,
|
|
4
5
|
defineWriteHandler,
|
|
@@ -18,7 +19,13 @@ import { AuthErrors, AuthHandlers } from "../../auth-email-password/index.js";
|
|
|
18
19
|
import { TenantHandlers, tenantTable } from "../../tenant/index.js";
|
|
19
20
|
import { DEFAULT_OWN_TENANT_INVITE_ROLE, WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
|
|
20
21
|
import { isOpenWaitlistStatus } from "../entity.js";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
adminAccess,
|
|
24
|
+
isTenantMember,
|
|
25
|
+
platformActor,
|
|
26
|
+
waitlistDb,
|
|
27
|
+
waitlistExecutor,
|
|
28
|
+
} from "../lib.js";
|
|
22
29
|
import type { WaitlistInviteOptions } from "../options.js";
|
|
23
30
|
import { WaitlistIdSchema } from "../payloads.js";
|
|
24
31
|
|
|
@@ -30,6 +37,11 @@ export type WaitlistInviteData = {
|
|
|
30
37
|
|
|
31
38
|
type InviteTarget = { readonly tenantId: TenantId; readonly role: string };
|
|
32
39
|
|
|
40
|
+
// pg_advisory_xact_lock namespace (int4): 'wlin' as ASCII, disjoint from the submit lock.
|
|
41
|
+
const INVITE_LOCK_NAMESPACE = 0x776c696e;
|
|
42
|
+
const INVITE_LOCK_REASON =
|
|
43
|
+
"takes the per-entry advisory lock so parallel invites cannot both create a tenant and invitation; TenantDb has no lock API";
|
|
44
|
+
|
|
33
45
|
const DEFAULT_INVITE: WaitlistInviteOptions = {
|
|
34
46
|
mode: "own-tenant",
|
|
35
47
|
role: DEFAULT_OWN_TENANT_INVITE_ROLE,
|
|
@@ -48,10 +60,11 @@ type EntryRow = {
|
|
|
48
60
|
readonly name: string;
|
|
49
61
|
readonly company: string | null;
|
|
50
62
|
readonly linkedTenantId: string | null;
|
|
63
|
+
readonly locale: string | undefined;
|
|
51
64
|
};
|
|
52
65
|
|
|
53
66
|
function readEntryRow(row: Record<string, unknown>): EntryRow {
|
|
54
|
-
const { id, version, email, name, company, linkedTenantId } = row;
|
|
67
|
+
const { id, version, email, name, company, linkedTenantId, locale } = row;
|
|
55
68
|
if (
|
|
56
69
|
typeof id !== "string" ||
|
|
57
70
|
typeof version !== "number" ||
|
|
@@ -68,6 +81,7 @@ function readEntryRow(row: Record<string, unknown>): EntryRow {
|
|
|
68
81
|
name,
|
|
69
82
|
company: typeof company === "string" && company !== "" ? company : null,
|
|
70
83
|
linkedTenantId: typeof linkedTenantId === "string" ? linkedTenantId : null,
|
|
84
|
+
locale: typeof locale === "string" && locale !== "" ? locale : undefined,
|
|
71
85
|
};
|
|
72
86
|
}
|
|
73
87
|
|
|
@@ -122,8 +136,17 @@ export function createInviteHandler(invite: WaitlistInviteOptions = DEFAULT_INVI
|
|
|
122
136
|
"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
137
|
// Sends an invite link by email, which cannot be recalled.
|
|
124
138
|
agent: { risk: "high" },
|
|
139
|
+
escapeHatch: { grants: ["unsafeRaw"], reason: INVITE_LOCK_REASON },
|
|
125
140
|
handler: async (event, ctx) => {
|
|
126
141
|
const db = waitlistDb(ctx);
|
|
142
|
+
if (!ctx.systemDb) {
|
|
143
|
+
throw new InternalError({ message: "waitlist:invite requires ctx.systemDb" });
|
|
144
|
+
}
|
|
145
|
+
await acquireNamespacedAdvisoryLock(
|
|
146
|
+
ctx.systemDb.unsafeRaw(INVITE_LOCK_REASON),
|
|
147
|
+
INVITE_LOCK_NAMESPACE,
|
|
148
|
+
event.payload.id,
|
|
149
|
+
);
|
|
127
150
|
const row = await waitlistExecutor.detail({ id: event.payload.id }, platformActor(), db);
|
|
128
151
|
if (!row) return writeFailure(new NotFoundError("waitlistEntry", event.payload.id));
|
|
129
152
|
const entry = readEntryRow(row);
|
|
@@ -134,13 +157,22 @@ export function createInviteHandler(invite: WaitlistInviteOptions = DEFAULT_INVI
|
|
|
134
157
|
const target = await resolveInviteTarget(invite, entry, ctx);
|
|
135
158
|
if ("isSuccess" in target) return target;
|
|
136
159
|
|
|
160
|
+
if (await isTenantMember(db, entry.email, target.tenantId)) {
|
|
161
|
+
return writeFailure(new UnprocessableError(WaitlistErrors.notInvitable));
|
|
162
|
+
}
|
|
163
|
+
|
|
137
164
|
// inviteCreate is admin-only (no system role); the system variant with an
|
|
138
165
|
// empty globalRoles list is the only in-process path, and a waitlist
|
|
139
166
|
// invite must never grant a global role.
|
|
140
167
|
const invited = await ctx.writeAs(
|
|
141
168
|
createSystemUser(target.tenantId),
|
|
142
169
|
AuthHandlers.systemInviteCreate,
|
|
143
|
-
{
|
|
170
|
+
{
|
|
171
|
+
email: entry.email.toLowerCase(),
|
|
172
|
+
role: target.role,
|
|
173
|
+
globalRoles: [],
|
|
174
|
+
...(entry.locale !== undefined && { locale: entry.locale }),
|
|
175
|
+
},
|
|
144
176
|
);
|
|
145
177
|
if (!invited.isSuccess) {
|
|
146
178
|
// Re-inviting a person who already accepted would reset their live invitation.
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
2
|
import {
|
|
4
3
|
createSystemUser,
|
|
5
4
|
defineWriteHandler,
|
|
6
5
|
type HandlerContext,
|
|
7
6
|
parseTenantId,
|
|
8
|
-
type TenantId,
|
|
9
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
8
|
import {
|
|
11
9
|
InternalError,
|
|
@@ -18,13 +16,12 @@ import {
|
|
|
18
16
|
cancelPendingInvitation,
|
|
19
17
|
INVITATION_STATUS,
|
|
20
18
|
tenantInvitationsTable,
|
|
21
|
-
tenantMembershipsTable,
|
|
22
19
|
} from "../../tenant/index.js";
|
|
23
|
-
import { userTable } from "../../user/index.js";
|
|
24
20
|
import { WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
|
|
25
21
|
import { isOpenWaitlistStatus } from "../entity.js";
|
|
26
22
|
import {
|
|
27
23
|
adminAccess,
|
|
24
|
+
isTenantMember,
|
|
28
25
|
normalizeEmail,
|
|
29
26
|
platformActor,
|
|
30
27
|
waitlistDb,
|
|
@@ -34,18 +31,6 @@ import { WaitlistIdSchema } from "../payloads.js";
|
|
|
34
31
|
|
|
35
32
|
export type WaitlistRejectData = { readonly kind: "rejected"; readonly id: string };
|
|
36
33
|
|
|
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
34
|
// An invited entry already has a live invitation link; rejecting must kill it,
|
|
50
35
|
// otherwise the rejected person can still sign up. Once accepted they hold
|
|
51
36
|
// access, which only tenant member removal may revoke.
|
|
@@ -36,10 +36,13 @@ const SUBMIT_ROLES = access.roles(
|
|
|
36
36
|
"TenantAdmin",
|
|
37
37
|
);
|
|
38
38
|
|
|
39
|
+
const OPEN_STATUSES = [WAITLIST_STATUS.Pending, WAITLIST_STATUS.Invited];
|
|
40
|
+
|
|
39
41
|
export type WaitlistSubmitData = { readonly kind: "submitted" };
|
|
40
42
|
|
|
41
|
-
// Identical for new, duplicate and honeypot submits
|
|
42
|
-
//
|
|
43
|
+
// Identical body for new, duplicate and honeypot submits so the response does not
|
|
44
|
+
// state whether the email is already on the list. Timing still differs: only the
|
|
45
|
+
// new-entry path writes and sends mail.
|
|
43
46
|
const SUBMITTED: WriteResult<WaitlistSubmitData> = {
|
|
44
47
|
isSuccess: true,
|
|
45
48
|
data: { kind: "submitted" },
|
|
@@ -60,13 +63,32 @@ function isHoneypotFilled(payload: WaitlistSubmitInput): boolean {
|
|
|
60
63
|
|
|
61
64
|
async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise<boolean> {
|
|
62
65
|
const found = await waitlistExecutor.list(
|
|
63
|
-
{
|
|
66
|
+
{
|
|
67
|
+
filter: { field: "email", op: "eq", value: email },
|
|
68
|
+
filters: [{ field: "status", op: "in", value: OPEN_STATUSES }],
|
|
69
|
+
limit: 1,
|
|
70
|
+
},
|
|
64
71
|
platformActor(),
|
|
65
72
|
waitlistDb(ctx),
|
|
66
73
|
);
|
|
67
|
-
return found.rows.
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
return found.rows.length > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The resolver usually reads config; run it outside the handler transaction's
|
|
78
|
+
// critical section so a failing statement cannot abort the transaction that holds
|
|
79
|
+
// the entry, and a slow resolver does not extend the per-email advisory lock.
|
|
80
|
+
async function resolveAdminRecipient(
|
|
81
|
+
opts: WaitlistOptions,
|
|
82
|
+
ctx: HandlerContext,
|
|
83
|
+
): Promise<string | null> {
|
|
84
|
+
try {
|
|
85
|
+
return (await opts.notifyRecipient?.(ctx)) || null;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
});
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
70
92
|
}
|
|
71
93
|
|
|
72
94
|
async function sendBestEffort(
|
|
@@ -87,14 +109,16 @@ async function sendBestEffort(
|
|
|
87
109
|
}
|
|
88
110
|
|
|
89
111
|
export function createSubmitHandler(opts: WaitlistOptions) {
|
|
112
|
+
const emailRateLimits = opts.emailRateLimits ?? DEFAULT_SUBMIT_EMAIL_RATE_LIMITS;
|
|
90
113
|
return defineWriteHandler<"submit", typeof WaitlistSubmitSchema, WaitlistSubmitData>({
|
|
91
114
|
name: "submit",
|
|
92
115
|
schema: WaitlistSubmitSchema,
|
|
93
116
|
access: { roles: SUBMIT_ROLES, personalData: "public-intake" },
|
|
94
117
|
rateLimit: opts.rateLimit ?? DEFAULT_SUBMIT_RATE_LIMIT,
|
|
95
|
-
|
|
118
|
+
// An empty array means "disabled"; boot validation rejects an empty additionalRateLimits.
|
|
119
|
+
...(emailRateLimits.length > 0 && { additionalRateLimits: emailRateLimits }),
|
|
96
120
|
description:
|
|
97
|
-
"Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail.
|
|
121
|
+
"Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail. Reports success whether the address is new or already on the list; the response body is identical, response timing can still differ.",
|
|
98
122
|
escapeHatch: { grants: ["unsafeRaw"], reason: SUBMIT_LOCK_REASON },
|
|
99
123
|
handler: async (event, ctx) => {
|
|
100
124
|
const payload = event.payload;
|
|
@@ -106,6 +130,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
|
|
|
106
130
|
message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
|
|
107
131
|
});
|
|
108
132
|
}
|
|
133
|
+
const adminRecipient = await resolveAdminRecipient(opts, ctx);
|
|
109
134
|
await acquireNamespacedAdvisoryLock(
|
|
110
135
|
ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON),
|
|
111
136
|
SUBMIT_LOCK_NAMESPACE,
|
|
@@ -140,7 +165,6 @@ export function createSubmitHandler(opts: WaitlistOptions) {
|
|
|
140
165
|
);
|
|
141
166
|
|
|
142
167
|
await sendBestEffort(ctx, "admin notice", async (notify) => {
|
|
143
|
-
const adminRecipient = await opts.notifyRecipient?.(ctx);
|
|
144
168
|
// skip: no admin recipient configured
|
|
145
169
|
if (!adminRecipient) return;
|
|
146
170
|
await notify(WAITLIST_NOTIFICATION_TYPES.adminNotice, {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
type LocalizedString = { readonly en: string; readonly de: string };
|
|
2
2
|
|
|
3
3
|
export const WAITLIST_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"waitlist:feature.label": { en: "Waitlist", de: "Warteliste" },
|
|
5
|
+
"waitlist:notify.confirmation.label": {
|
|
6
|
+
en: "Waitlist confirmation",
|
|
7
|
+
de: "Bestätigung der Warteliste",
|
|
8
|
+
},
|
|
9
|
+
"waitlist:notify.admin-notice.label": {
|
|
10
|
+
en: "New waitlist entry",
|
|
11
|
+
de: "Neuer Eintrag auf der Warteliste",
|
|
12
|
+
},
|
|
4
13
|
"screen:waitlist-list.title": { en: "Waitlist", de: "Warteliste" },
|
|
5
14
|
"waitlist.nav.waitlist": { en: "Waitlist", de: "Warteliste" },
|
|
6
15
|
"waitlist:entity:waitlistEntry:field:name": { en: "Name", de: "Name" },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
2
|
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import {
|
|
3
4
|
access,
|
|
@@ -5,8 +6,11 @@ import {
|
|
|
5
6
|
type HandlerContext,
|
|
6
7
|
type SessionUser,
|
|
7
8
|
SYSTEM_TENANT_ID,
|
|
9
|
+
type TenantId,
|
|
8
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
11
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { tenantMembershipsTable } from "../tenant/index.js";
|
|
13
|
+
import { userTable } from "../user/index.js";
|
|
10
14
|
import { waitlistEntryEntity, waitlistEntryTable } from "./entity.js";
|
|
11
15
|
|
|
12
16
|
export const WAITLIST_CROSS_TENANT_REASON = "waitlist is platform-wide (systemScope)";
|
|
@@ -34,3 +38,19 @@ export function waitlistDb(ctx: HandlerContext): TenantDb {
|
|
|
34
38
|
export function platformActor(): SessionUser {
|
|
35
39
|
return createSystemUser(SYSTEM_TENANT_ID);
|
|
36
40
|
}
|
|
41
|
+
|
|
42
|
+
// An admin re-invite can flip an accepted invitation back to pending, so the
|
|
43
|
+
// invitation status alone cannot prove the person holds no access.
|
|
44
|
+
export async function isTenantMember(
|
|
45
|
+
db: TenantDb,
|
|
46
|
+
email: string,
|
|
47
|
+
tenantId: TenantId,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
const user = await fetchOne<{ id: string }>(db, userTable, {
|
|
50
|
+
email: normalizeEmail(email),
|
|
51
|
+
isDeleted: false,
|
|
52
|
+
});
|
|
53
|
+
if (!user) return false;
|
|
54
|
+
const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
|
|
55
|
+
return membership !== undefined && membership !== null;
|
|
56
|
+
}
|
|
@@ -38,5 +38,5 @@ export const DEFAULT_SUBMIT_RATE_LIMIT: RateLimitOption = {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
export const DEFAULT_SUBMIT_EMAIL_RATE_LIMITS: readonly PayloadRateLimitOption[] = [
|
|
41
|
-
{ per: { payloadField: "email" }, limit: 3, windowSeconds: 24 * 60 * 60 },
|
|
41
|
+
{ per: { payloadField: "email" }, limit: 3, windowSeconds: 24 * 60 * 60, normalize: "email" },
|
|
42
42
|
];
|