@cosmicdrift/kumiko-samples 0.387.0 → 0.388.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 +8 -1
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
- package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
- package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
- package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
- package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
- package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
- package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
- package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
- package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
- package/packages/bundled-features/src/cap-counter/changes.json +18 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/changes.json +12 -0
- package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
- package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
- package/packages/bundled-features/src/config/write-helpers.ts +27 -8
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
- package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
- package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
- package/packages/bundled-features/src/file-foundation/index.ts +1 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/form-draft/changes.json +6 -0
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
- package/packages/bundled-features/src/host-routing/README.md +1 -1
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
- package/packages/bundled-features/src/host-routing/options.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
- package/packages/bundled-features/src/jobs/changes.json +13 -0
- package/packages/bundled-features/src/jobs/feature.ts +14 -4
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
- package/packages/bundled-features/src/legal-pages/README.md +1 -1
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
- package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
- package/packages/bundled-features/src/sessions/feature.ts +7 -2
- package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
- package/packages/bundled-features/src/shared/index.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
- package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
- package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
- package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
- package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
- package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
- package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
- package/packages/bundled-features/src/template-resolver/api.ts +31 -4
- package/packages/bundled-features/src/template-resolver/changes.json +48 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
- package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
- package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
- package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
- package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
- package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
- package/packages/bundled-features/src/waitlist/import.ts +5 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
- package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
- package/packages/bundled-features/src/waitlist/options.ts +3 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
- package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
- package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
- package/samples/recipes/auth-mfa/README.md +5 -0
- package/samples/recipes/auth-mfa/package.json +5 -2
- package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
- package/samples/recipes/auth-mfa/tsconfig.json +8 -0
- package/samples/recipes/env-composition/README.md +3 -1
- package/samples/recipes/env-composition/src/env.ts +2 -0
- package/samples/recipes/legal-pages/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +18 -0
- package/samples/recipes/rate-limiting/src/feature.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
|
@@ -50,6 +50,8 @@ export const memberDirectoryQuery = definePagedQueryHandler({
|
|
|
50
50
|
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
|
|
51
51
|
// Sent by the reference combobox while the user types.
|
|
52
52
|
search: z.string().trim().min(1).optional(),
|
|
53
|
+
// Resolves exactly these users; callers that know their ids must not depend on the cap.
|
|
54
|
+
ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
|
|
53
55
|
}),
|
|
54
56
|
access: { roles: access.admin },
|
|
55
57
|
description:
|
|
@@ -62,7 +64,8 @@ export const memberDirectoryQuery = definePagedQueryHandler({
|
|
|
62
64
|
"tenant:query:member-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
|
-
const { limit, search } = query.payload;
|
|
67
|
+
const { limit, search, ids } = query.payload;
|
|
68
|
+
if (ids?.length === 0) return { rows: [], nextCursor: null };
|
|
66
69
|
// Without a search the first `limit` rows are enough; with one, every
|
|
67
70
|
// candidate must be decrypted before the limit can apply.
|
|
68
71
|
const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
|
|
@@ -71,13 +74,16 @@ export const memberDirectoryQuery = definePagedQueryHandler({
|
|
|
71
74
|
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
72
75
|
"SystemAdmin reference labels span every tenant, as user:query:user:list did",
|
|
73
76
|
);
|
|
74
|
-
users = await selectMany(db, userTable, undefined, {
|
|
77
|
+
users = await selectMany(db, userTable, ids === undefined ? undefined : { id: ids }, {
|
|
75
78
|
limit: scanLimit,
|
|
76
79
|
orderBy: { col: "id", direction: "asc" },
|
|
77
80
|
});
|
|
78
81
|
} else {
|
|
79
82
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
80
|
-
const
|
|
83
|
+
const requestedIds = ids === undefined ? undefined : new Set(ids);
|
|
84
|
+
const userIds = (await loadMemberUserIds(db, query.user.tenantId))
|
|
85
|
+
.filter((id) => requestedIds === undefined || requestedIds.has(id))
|
|
86
|
+
.slice(0, scanLimit);
|
|
81
87
|
users =
|
|
82
88
|
userIds.length > 0
|
|
83
89
|
? await selectMany(
|
|
@@ -27,10 +27,12 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
27
27
|
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
|
|
28
28
|
// Sent by the reference combobox while the user types.
|
|
29
29
|
search: z.string().trim().min(1).optional(),
|
|
30
|
+
// Resolves exactly these tenants; callers that know their ids must not depend on the cap.
|
|
31
|
+
ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
|
|
30
32
|
}),
|
|
31
33
|
access: { roles: access.admin },
|
|
32
34
|
description:
|
|
33
|
-
"Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin.",
|
|
35
|
+
"Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin. A search returns the first `limit` matches in tenant-key order (sorted by label afterwards), not the alphabetically best matches.",
|
|
34
36
|
agent: { expose: false },
|
|
35
37
|
handler: async (query, ctx) => {
|
|
36
38
|
if (!ctx.systemDb) {
|
|
@@ -39,14 +41,15 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
39
41
|
"tenant:query:tenant-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
40
42
|
});
|
|
41
43
|
}
|
|
42
|
-
const { limit, search } = query.payload;
|
|
44
|
+
const { limit, search, ids } = query.payload;
|
|
45
|
+
if (ids?.length === 0) return { rows: [], nextCursor: null };
|
|
43
46
|
const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
|
|
44
47
|
let tenants: readonly { id: unknown; name?: unknown }[];
|
|
45
48
|
if (isSystemAdmin(query.user)) {
|
|
46
49
|
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
47
50
|
"SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did",
|
|
48
51
|
);
|
|
49
|
-
tenants = await selectMany(db, tenantTable, undefined, {
|
|
52
|
+
tenants = await selectMany(db, tenantTable, ids === undefined ? undefined : { id: ids }, {
|
|
50
53
|
limit: scanLimit,
|
|
51
54
|
orderBy: [
|
|
52
55
|
{ col: "key", direction: "asc" },
|
|
@@ -56,7 +59,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
56
59
|
} else {
|
|
57
60
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
58
61
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
59
|
-
tenants = row ? [row] : [];
|
|
62
|
+
tenants = row && (ids === undefined || ids.includes(query.user.tenantId)) ? [row] : [];
|
|
60
63
|
}
|
|
61
64
|
// name is ciphertext at rest, so the SQL page is keyed by the plaintext
|
|
62
65
|
// `key`; label order and search happen in memory after decrypting.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Destroy sweep keeps a minimum time slice per tenant",
|
|
6
|
+
"detail": "With many tenants in destroying state the shared tick budget no longer shrinks a tenant's slice below two seconds, so chunking hooks make progress instead of failing the stage as no-progress attempts."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.365.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -62,3 +62,8 @@ export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[numb
|
|
|
62
62
|
// The sweep cron fires every minute with concurrency "skip", so a tick must
|
|
63
63
|
// finish well inside that window or the next one is skipped.
|
|
64
64
|
export const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30_000;
|
|
65
|
+
|
|
66
|
+
// Lower bound of a tenant's share of the tick budget. Below it a chunking hook
|
|
67
|
+
// without a guaranteed first batch processes nothing, and a no-progress stage
|
|
68
|
+
// counts as a failed attempt, which would abandon the pipeline under load.
|
|
69
|
+
export const DESTRUCTION_STAGE_MIN_SLICE_MS = 2_000;
|
|
@@ -23,6 +23,7 @@ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
|
23
23
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
24
|
import { tenantEntity, tenantTable } from "../tenant/index.ts";
|
|
25
25
|
import {
|
|
26
|
+
DESTRUCTION_STAGE_MIN_SLICE_MS,
|
|
26
27
|
DESTRUCTION_STAGE_TICK_BUDGET_MS,
|
|
27
28
|
TENANT_AGGREGATE_TYPE,
|
|
28
29
|
TENANT_DESTRUCTION_COMPLETED_EVENT_QN,
|
|
@@ -461,7 +462,10 @@ export async function runTenantDestructionSweep(args: {
|
|
|
461
462
|
// window and starve the ones behind it. Each slice starts when its tenant
|
|
462
463
|
// starts: anchoring to a shared tick deadline would hand tenants after an
|
|
463
464
|
// overrun a zero slice, and a no-progress stage counts as a failed attempt.
|
|
464
|
-
const sliceMs =
|
|
465
|
+
const sliceMs = Math.max(
|
|
466
|
+
DESTRUCTION_STAGE_MIN_SLICE_MS,
|
|
467
|
+
DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length),
|
|
468
|
+
);
|
|
465
469
|
for (const row of destroying) {
|
|
466
470
|
const tenantDeadlineAt = Date.now() + sliceMs;
|
|
467
471
|
try {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "set-tenant-tier no longer accepts source default",
|
|
6
|
+
"detail": "The source field of set-tenant-tier is limited to manual and billing. The value default is reserved for the signup default tier and made a manual grant look like a billing row that the next sync overwrites."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -59,7 +59,10 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
59
59
|
.min(1)
|
|
60
60
|
.max(50)
|
|
61
61
|
.refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
|
|
62
|
-
|
|
62
|
+
// `default` is reserved for the signup default tier; the billing sync treats it like `billing`.
|
|
63
|
+
source: z
|
|
64
|
+
.enum([TierAssignmentSources.manual, TierAssignmentSources.billing])
|
|
65
|
+
.default(TierAssignmentSources.manual),
|
|
63
66
|
}),
|
|
64
67
|
access: { roles: ["SystemAdmin"] },
|
|
65
68
|
agent: { risk: "high" },
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// category in the kumiko isolation model (runtime may import client, client
|
|
4
4
|
// may only import client) — otherwise the runtime isolation guard throws on
|
|
5
5
|
// the client imports. Same pattern as text-content/constants.
|
|
6
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
|
+
|
|
6
8
|
// Feature name
|
|
7
9
|
export const TIER_ENGINE_FEATURE = "tier-engine" as const;
|
|
8
10
|
|
|
@@ -24,10 +26,8 @@ export const TierAssignmentSources = {
|
|
|
24
26
|
export type TierAssignmentSource =
|
|
25
27
|
(typeof TierAssignmentSources)[keyof typeof TierAssignmentSources];
|
|
26
28
|
|
|
27
|
-
const TIER_ASSIGNMENT_SOURCE_VALUES: readonly string[] = Object.values(TierAssignmentSources);
|
|
28
|
-
|
|
29
29
|
export function isTierAssignmentSource(value: unknown): value is TierAssignmentSource {
|
|
30
|
-
return
|
|
30
|
+
return isOneOf(Object.values(TierAssignmentSources), value);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Qualified query handler names.
|
|
@@ -21,7 +21,6 @@ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
|
21
21
|
// storage indefinitely.
|
|
22
22
|
|
|
23
23
|
import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
24
|
-
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
25
24
|
import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
26
25
|
import {
|
|
27
26
|
createEventStoreExecutor,
|
|
@@ -44,7 +43,7 @@ import {
|
|
|
44
43
|
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
45
44
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
46
45
|
import { resolveProfileForTenant } from "../compliance-profiles/index.ts";
|
|
47
|
-
import {
|
|
46
|
+
import { decryptStoredUserEmail } from "../shared/index.ts";
|
|
48
47
|
import { userTable } from "../user/index.ts";
|
|
49
48
|
import { selectExportJobsForStorageCleanup } from "./db/queries/export-jobs.ts";
|
|
50
49
|
import { runUserExport, type UserExportBundle } from "./run-user-export.ts";
|
|
@@ -822,8 +821,8 @@ async function lookupUserContact(
|
|
|
822
821
|
locale: string | null;
|
|
823
822
|
} | null;
|
|
824
823
|
if (!row?.email) return null;
|
|
825
|
-
const email = await
|
|
826
|
-
if (email ===
|
|
824
|
+
const email = await decryptStoredUserEmail(row.email, "user-data-rights:export-ready");
|
|
825
|
+
if (email === null) return null;
|
|
827
826
|
return { email, locale: row.locale ?? null };
|
|
828
827
|
}
|
|
829
828
|
|
|
@@ -518,7 +518,8 @@ async function processUser(args: {
|
|
|
518
518
|
// is what makes this crash-safe: eraseKey-throw → tx rollback → user
|
|
519
519
|
// stays DeletionRequested → next run retries; flip-throw after a
|
|
520
520
|
// successful erase → retry re-runs the idempotent hooks and eraseKey
|
|
521
|
-
// (
|
|
521
|
+
// (idempotent: a repeat erase keeps the original tombstone, an unknown
|
|
522
|
+
// subject gets one). Erasing after
|
|
522
523
|
// the flip instead would risk a permanent silent miss: user already
|
|
523
524
|
// Deleted, no run ever picks him up again, key stays live.
|
|
524
525
|
if (kms) {
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Link-on-signup failures, legacy re-run and purge audit order",
|
|
6
|
+
"detail": "link-on-signup throws when the entry update fails and the entry is still invited, so the consumer redelivers. The offlot legacy migration skips rows that are already imports. purge writes its audit event before shredding keys."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.388.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Admin notice language comes from adminLocale",
|
|
12
|
+
"detail": "The admin notice mail used the request locale, which is the entrant's browser language. The new adminLocale option (en, de or es, default en) sets the operator's language."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.388.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Legacy migration dry run counts the streams it would archive",
|
|
18
|
+
"detail": "The dry run now runs the read-only archive checks and reports archivedStreams without writing; the CLI prints \"would archive\" for it."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.379.0",
|
|
4
22
|
"type": "improvement",
|
|
@@ -74,8 +74,9 @@ export const purgeHandler = defineWriteHandler<"purge", typeof PurgeSchema, Wait
|
|
|
74
74
|
}),
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
// Audit first: purgeWaitlistRows shreds KMS keys, which a later failure cannot roll back.
|
|
78
78
|
await appendAudit(ctx, WAITLIST_PURGED_EVENT_QN, rows.length);
|
|
79
|
+
await purgeWaitlistRows(db, rows, event.user.id);
|
|
79
80
|
return { isSuccess: true, data: { purged: rows.length } };
|
|
80
81
|
},
|
|
81
82
|
});
|
|
@@ -73,9 +73,10 @@ async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise
|
|
|
73
73
|
return found.rows.length > 0;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// The resolver usually reads config
|
|
77
|
-
//
|
|
78
|
-
//
|
|
76
|
+
// The resolver usually reads config. It runs before the per-email advisory lock so a
|
|
77
|
+
// slow resolver does not extend the lock. It still shares the handler transaction: a
|
|
78
|
+
// thrown JS error is swallowed here, but a failed SQL statement inside it would abort
|
|
79
|
+
// the transaction, so resolvers must not issue statements that can fail.
|
|
79
80
|
async function resolveAdminRecipient(
|
|
80
81
|
opts: WaitlistOptions,
|
|
81
82
|
ctx: HandlerContext,
|
|
@@ -175,8 +176,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
|
|
|
175
176
|
message: payload.message,
|
|
176
177
|
}),
|
|
177
178
|
priority: "normal",
|
|
178
|
-
|
|
179
|
-
locale: resolveWaitlistMailLocale(ctx.locale),
|
|
179
|
+
locale: resolveWaitlistMailLocale(opts.adminLocale),
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
182
|
|
|
@@ -38,6 +38,11 @@ export function waitlistImportId(sourceKey: string): string {
|
|
|
38
38
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// The version nibble marks ids minted by waitlistImportId; legacy row ids are random UUIDs.
|
|
42
|
+
export function isWaitlistImportId(id: string): boolean {
|
|
43
|
+
return id[14] === "5";
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
function presentOnly(fields: Record<string, unknown>): Record<string, unknown> {
|
|
42
47
|
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
43
48
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createTenantDb,
|
|
10
10
|
selectMany,
|
|
11
11
|
table,
|
|
12
|
+
tableExists,
|
|
12
13
|
text as textColumn,
|
|
13
14
|
timestamp,
|
|
14
15
|
transaction,
|
|
@@ -23,9 +24,16 @@ import {
|
|
|
23
24
|
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
25
|
import type { DbConnection } from "@cosmicdrift/kumiko-types/db-connection";
|
|
25
26
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
importWaitlistEntry,
|
|
29
|
+
isWaitlistImportId,
|
|
30
|
+
type WaitlistImportEntry,
|
|
31
|
+
waitlistImportId,
|
|
32
|
+
} from "./import.ts";
|
|
27
33
|
import { WAITLIST_STATUS, type WaitlistStatus } from "./web/constants.ts";
|
|
28
34
|
|
|
35
|
+
const ARCHIVED_STREAMS_TABLE = "public.kumiko_archived_streams";
|
|
36
|
+
|
|
29
37
|
export const LEGACY_WAITLIST_SOURCES = ["offlot", "publicstatus"] as const;
|
|
30
38
|
export type LegacyWaitlistSource = (typeof LEGACY_WAITLIST_SOURCES)[number];
|
|
31
39
|
|
|
@@ -210,15 +218,19 @@ async function archiveLegacyStream(
|
|
|
210
218
|
db: DbConnection,
|
|
211
219
|
legacyId: string,
|
|
212
220
|
importedId: string,
|
|
221
|
+
dryRun: boolean,
|
|
213
222
|
): Promise<number> {
|
|
214
223
|
if (legacyId === importedId) return 0;
|
|
215
|
-
|
|
224
|
+
// A dry run never creates the archive table, so a missing one means nothing is archived yet.
|
|
225
|
+
const archiveTableReady = !dryRun || (await tableExists(db, ARCHIVED_STREAMS_TABLE));
|
|
226
|
+
if (archiveTableReady && (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId))) return 0;
|
|
216
227
|
const legacyEvents = await countWhere(db, eventsTable, {
|
|
217
228
|
tenantId: SYSTEM_TENANT_ID,
|
|
218
229
|
aggregateId: legacyId,
|
|
219
230
|
aggregateType: LEGACY_AGGREGATE_TYPE,
|
|
220
231
|
});
|
|
221
232
|
if (legacyEvents === 0) return 0;
|
|
233
|
+
if (dryRun) return 1;
|
|
222
234
|
await archiveStream(db, {
|
|
223
235
|
tenantId: SYSTEM_TENANT_ID,
|
|
224
236
|
aggregateId: legacyId,
|
|
@@ -243,6 +255,12 @@ export async function migrateLegacyWaitlist(
|
|
|
243
255
|
let archivedStreams = 0;
|
|
244
256
|
if (!options.dryRun && options.from === "offlot") await createArchivedStreamsTable(db);
|
|
245
257
|
for (const row of rows) {
|
|
258
|
+
// The offlot default source is the entity table: rows a previous run imported
|
|
259
|
+
// are already migrated entries and must neither be re-imported nor archived.
|
|
260
|
+
if (options.from === "offlot" && isWaitlistImportId(String(row["id"]))) {
|
|
261
|
+
skipped += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
246
264
|
const entry =
|
|
247
265
|
options.from === "offlot"
|
|
248
266
|
? await mapOfflotRow(row)
|
|
@@ -257,11 +275,12 @@ export async function migrateLegacyWaitlist(
|
|
|
257
275
|
if (outcome === "created") created += 1;
|
|
258
276
|
else skipped += 1;
|
|
259
277
|
});
|
|
260
|
-
if (
|
|
278
|
+
if (options.from === "offlot") {
|
|
261
279
|
archivedStreams += await archiveLegacyStream(
|
|
262
280
|
db,
|
|
263
281
|
String(row["id"]),
|
|
264
282
|
waitlistImportId(entry.sourceKey),
|
|
283
|
+
options.dryRun,
|
|
265
284
|
);
|
|
266
285
|
}
|
|
267
286
|
}
|
|
@@ -75,22 +75,25 @@ export const linkWaitlistOnSignup: MultiStreamApplyFn = async (event, tx) => {
|
|
|
75
75
|
if (typeof user?.email !== "string") return;
|
|
76
76
|
const email = normalizeEmail(await decryptStoredPii(user.email, "email", PII_DECRYPT_REASON));
|
|
77
77
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
const findInvited = async () => {
|
|
79
|
+
const found = await waitlistExecutor.list(
|
|
80
|
+
{
|
|
81
|
+
filter: { field: "email", op: "eq", value: email },
|
|
82
|
+
filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
|
|
83
|
+
},
|
|
84
|
+
platformActor(),
|
|
85
|
+
db,
|
|
86
|
+
);
|
|
87
|
+
return [...found.rows].sort(newestSubmittedFirst)[0];
|
|
88
|
+
};
|
|
89
|
+
const invited = await findInvited();
|
|
87
90
|
// skip: the signup email has no invited waitlist entry (open signup or stale invite).
|
|
88
91
|
if (!invited) return;
|
|
89
92
|
const { id, version } = invited;
|
|
90
93
|
// skip: defensive guard on the row shape.
|
|
91
94
|
if (typeof id !== "string" || typeof version !== "number") return;
|
|
92
95
|
|
|
93
|
-
await waitlistExecutor.update(
|
|
96
|
+
const updated = await waitlistExecutor.update(
|
|
94
97
|
{
|
|
95
98
|
id,
|
|
96
99
|
version,
|
|
@@ -103,4 +106,11 @@ export const linkWaitlistOnSignup: MultiStreamApplyFn = async (event, tx) => {
|
|
|
103
106
|
platformActor(),
|
|
104
107
|
db,
|
|
105
108
|
);
|
|
109
|
+
// skip: linked.
|
|
110
|
+
if (updated.isSuccess) return;
|
|
111
|
+
// A concurrent admin change that already moved the entry out of `invited` makes the link
|
|
112
|
+
// moot; otherwise throw so the at-least-once consumer redelivers.
|
|
113
|
+
// skip: the entry left `invited` concurrently.
|
|
114
|
+
if ((await findInvited())?.id !== id) return;
|
|
115
|
+
throw new Error(`waitlist:link-on-signup: could not link waitlist entry ${id}`);
|
|
106
116
|
};
|
|
@@ -24,6 +24,9 @@ export type WaitlistOptions = {
|
|
|
24
24
|
* string or an absent option sends none. Best effort, never fails the submit,
|
|
25
25
|
* also when the resolver throws or rejects. */
|
|
26
26
|
readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
|
|
27
|
+
/** Language of the admin notice mail (en, de or es; anything else falls back to en).
|
|
28
|
+
* Not taken from the request: a submit comes from the entrant, not the operator. */
|
|
29
|
+
readonly adminLocale?: string;
|
|
27
30
|
/** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
|
|
28
31
|
readonly invite?: WaitlistInviteOptions;
|
|
29
32
|
/** Default: 5 submits per IP per 10 minutes. Pass `{ disabled: true, reason }`
|
|
@@ -119,6 +119,17 @@ export const WIDGETS_I18N = {
|
|
|
119
119
|
"widgets:catalog:status-maintenance": { de: "maintenance", en: "maintenance" },
|
|
120
120
|
"widgets:catalog:repayment-model": { de: "Tilgungsmodell", en: "Repayment model" },
|
|
121
121
|
"widgets:catalog:mode-annuity": { de: "Annuität", en: "Annuity" },
|
|
122
|
+
"widgets:catalog:choice-cards": { de: "Zahlungsanbieter", en: "Payment provider" },
|
|
123
|
+
"widgets:catalog:choice-direct": { de: "Direkt", en: "Direct" },
|
|
124
|
+
"widgets:catalog:choice-direct-description": {
|
|
125
|
+
de: "Rechnung vom Anbieter der App",
|
|
126
|
+
en: "Invoiced by the app operator",
|
|
127
|
+
},
|
|
128
|
+
"widgets:catalog:choice-reseller": { de: "Reseller", en: "Reseller" },
|
|
129
|
+
"widgets:catalog:choice-reseller-description": {
|
|
130
|
+
de: "Verkäufer ist der Reseller, inklusive Umsatzsteuer",
|
|
131
|
+
en: "Sold by the reseller, VAT included",
|
|
132
|
+
},
|
|
122
133
|
"widgets:catalog:mode-fixed": { de: "Feste Rate", en: "Fixed rate" },
|
|
123
134
|
"widgets:catalog:model": { de: "Modell", en: "Model" },
|
|
124
135
|
"widgets:catalog:nominal-rate": { de: "Sollzins", en: "Nominal rate" },
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
AiTextArea,
|
|
7
7
|
AiTextField,
|
|
8
8
|
BooleanField,
|
|
9
|
+
ChoiceCards,
|
|
9
10
|
CollapsibleSection,
|
|
10
11
|
ComparisonTable,
|
|
11
12
|
DateField,
|
|
@@ -50,6 +51,7 @@ export function Widgets(): ReactNode {
|
|
|
50
51
|
const t = useTranslation();
|
|
51
52
|
const locale = useLocale().locale();
|
|
52
53
|
const [mode, setMode] = useState<"annuity" | "fixed">("annuity");
|
|
54
|
+
const [provider, setProvider] = useState<"direct" | "reseller">("direct");
|
|
53
55
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
54
56
|
const [belowHeaderDrawerOpen, setBelowHeaderDrawerOpen] = useState(false);
|
|
55
57
|
const [floatingPanelOpen, setFloatingPanelOpen] = useState(false);
|
|
@@ -181,6 +183,28 @@ export function Widgets(): ReactNode {
|
|
|
181
183
|
/>
|
|
182
184
|
</SectionCard>
|
|
183
185
|
|
|
186
|
+
<SectionCard title={t("widgets:catalog:choice-cards")}>
|
|
187
|
+
<ChoiceCards
|
|
188
|
+
value={provider}
|
|
189
|
+
onChange={setProvider}
|
|
190
|
+
ariaLabel={t("widgets:catalog:choice-cards")}
|
|
191
|
+
options={[
|
|
192
|
+
{
|
|
193
|
+
value: "direct",
|
|
194
|
+
title: t("widgets:catalog:choice-direct"),
|
|
195
|
+
description: t("widgets:catalog:choice-direct-description"),
|
|
196
|
+
aside: "9,00 €",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
value: "reseller",
|
|
200
|
+
title: t("widgets:catalog:choice-reseller"),
|
|
201
|
+
description: t("widgets:catalog:choice-reseller-description"),
|
|
202
|
+
aside: "9,90 €",
|
|
203
|
+
},
|
|
204
|
+
]}
|
|
205
|
+
/>
|
|
206
|
+
</SectionCard>
|
|
207
|
+
|
|
184
208
|
<CollapsibleSection title={t("widgets:catalog:advanced-settings")}>
|
|
185
209
|
<EmptyState
|
|
186
210
|
title={t("widgets:catalog:no-extra-repayments-title")}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// runs in a Playwright worker and must not pull the server import chain).
|
|
5
5
|
|
|
6
6
|
export const GALLERY_PROVIDER = "gallery-mock-provider";
|
|
7
|
+
export const GALLERY_MOR_PROVIDER = "gallery-mock-mor";
|
|
7
8
|
export const GALLERY_TERMS_SLUG = "gallery-terms";
|
|
8
9
|
export const GALLERY_OPERATOR_EMAIL = "billing@gallery.example";
|
|
9
10
|
export const GALLERY_PLAN_PATH = "/tenant-admin/billing-plans";
|
|
@@ -14,3 +15,7 @@ export const GALLERY_TENANT_HOST = "localhost";
|
|
|
14
15
|
export const GALLERY_APEX_HOST = "127.0.0.1";
|
|
15
16
|
|
|
16
17
|
export const GALLERY_PRICE_IDS = { starter: "price_gallery_starter", pro: "price_gallery_pro" };
|
|
18
|
+
export const GALLERY_MOR_PRICE_IDS = {
|
|
19
|
+
starter: "price_gallery_mor_starter",
|
|
20
|
+
pro: "price_gallery_mor_pro",
|
|
21
|
+
};
|