@cosmicdrift/kumiko-samples 0.404.1 → 0.405.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 +10 -10
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
- package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
- package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
- package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
- package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
- package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
- package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
- package/packages/bundled-features/src/folders/feature.ts +2 -8
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
- package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
- package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
- package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
- package/packages/bundled-features/src/ledger/feature.ts +2 -7
- package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
- package/packages/bundled-features/src/notes-history/feature.ts +7 -8
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
- package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
- package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
- package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
- package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
- package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
- package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
- package/packages/bundled-features/src/shared/signed-token.ts +4 -14
- package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
- package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
- package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
- package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
- package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
- package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
- package/packages/bundled-features/src/tags/feature.ts +2 -8
- package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
- package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
- package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
- package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import {
|
|
20
|
+
createTtlPriceCache,
|
|
20
21
|
KNOWN_RECURRING_INTERVALS,
|
|
21
22
|
type ProviderPrice,
|
|
22
23
|
type ProviderSubscriptionSnapshot,
|
|
23
24
|
type RecurringInterval,
|
|
24
25
|
SubscriptionCancelTimings,
|
|
25
26
|
type SubscriptionProviderPlugin,
|
|
27
|
+
type TtlPriceCache,
|
|
26
28
|
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
27
29
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
30
|
import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -332,10 +334,7 @@ function mapStripePrice(price: Stripe.Price): ProviderPrice {
|
|
|
332
334
|
// requests the billing-plans catalog re-renders on every page load. Keyed by
|
|
333
335
|
// priceId alone: Stripe priceIds are account-wide unique. Failed lookups are
|
|
334
336
|
// never cached, so a transient Stripe outage self-heals on the next call.
|
|
335
|
-
export type StripePriceCache =
|
|
336
|
-
readonly get: (priceId: string) => Stripe.Price | undefined;
|
|
337
|
-
readonly set: (priceId: string, price: Stripe.Price) => void;
|
|
338
|
-
};
|
|
337
|
+
export type StripePriceCache = TtlPriceCache<Stripe.Price>;
|
|
339
338
|
|
|
340
339
|
export type StripePriceCacheOptions = {
|
|
341
340
|
readonly ttlMs?: number;
|
|
@@ -343,24 +342,10 @@ export type StripePriceCacheOptions = {
|
|
|
343
342
|
};
|
|
344
343
|
|
|
345
344
|
export function createStripePriceCache({
|
|
346
|
-
ttlMs
|
|
345
|
+
ttlMs,
|
|
347
346
|
now = () => Date.now(),
|
|
348
347
|
}: StripePriceCacheOptions = {}): StripePriceCache {
|
|
349
|
-
|
|
350
|
-
return {
|
|
351
|
-
get: (priceId) => {
|
|
352
|
-
const entry = store.get(priceId);
|
|
353
|
-
if (!entry) return undefined;
|
|
354
|
-
if (entry.expiresAt <= now()) {
|
|
355
|
-
store.delete(priceId);
|
|
356
|
-
return undefined;
|
|
357
|
-
}
|
|
358
|
-
return entry.price;
|
|
359
|
-
},
|
|
360
|
-
set: (priceId, price) => {
|
|
361
|
-
store.set(priceId, { price, expiresAt: now() + ttlMs });
|
|
362
|
-
},
|
|
363
|
-
};
|
|
348
|
+
return createTtlPriceCache<Stripe.Price>({ ttlMs, now });
|
|
364
349
|
}
|
|
365
350
|
|
|
366
351
|
/** Fills `cache` for every priceId not already resolved — the only place
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// un-audited framework-internal path). The foundation builds and passes
|
|
26
26
|
// this SecretsContext as the 3rd webhook arg.
|
|
27
27
|
|
|
28
|
+
import { parseStoredSecret } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
28
29
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
29
30
|
import {
|
|
30
31
|
type ConfigKeyHandle,
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
34
35
|
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
35
36
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
36
|
-
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
37
37
|
import Stripe from "stripe";
|
|
38
38
|
import { SUBSCRIPTION_STRIPE_FEATURE } from "./constants.ts";
|
|
39
39
|
|
|
@@ -63,22 +63,6 @@ export type StripeRuntimeDeps = {
|
|
|
63
63
|
readonly fallback: { readonly apiKey?: string; readonly webhookSecret?: string };
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
-
// backing:"secrets" config keys store their value JSON-serialized in the
|
|
67
|
-
// secrets store (config:write:set → JSON.stringify). A raw SecretsContext.get
|
|
68
|
-
// (the un-audited webhook path + the audited ctx read here) therefore returns
|
|
69
|
-
// the JSON-quoted string and must parse it back. The factory fallback is a
|
|
70
|
-
// plain env string and stays raw — so parsing is applied only to the store
|
|
71
|
-
// read, never to the fallback. The error must not carry the input: the JSON
|
|
72
|
-
// parser's own message would repeat the credential into logs and the 401 body.
|
|
73
|
-
function parseStoredSecret(raw: string | undefined): string | undefined {
|
|
74
|
-
if (raw === undefined || raw.length === 0) return undefined;
|
|
75
|
-
const parsed = parseJsonSafe<unknown>(raw, undefined);
|
|
76
|
-
if (parsed === undefined) {
|
|
77
|
-
throw new Error("subscription-stripe credential (backing:secrets) is not valid JSON");
|
|
78
|
-
}
|
|
79
|
-
return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
66
|
/** Post-tenant runtime: used by checkout/portal/cancel which carry a full
|
|
83
67
|
* HandlerContext (audited secret reads, config-gate). */
|
|
84
68
|
export type StripeCtxRuntime = {
|
|
@@ -119,7 +103,7 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
|
|
|
119
103
|
{ secrets: ctx.services.secrets, user: ctx.user },
|
|
120
104
|
SUBSCRIPTION_STRIPE_FEATURE,
|
|
121
105
|
).get(SYSTEM_TENANT_ID, deps.apiKeyHandle.name);
|
|
122
|
-
key = parseStoredSecret(got?.reveal());
|
|
106
|
+
key = parseStoredSecret("subscription-stripe", got?.reveal());
|
|
123
107
|
}
|
|
124
108
|
if (!key && deps.fallback.apiKey && deps.fallback.apiKey.length > 0) {
|
|
125
109
|
key = deps.fallback.apiKey;
|
|
@@ -143,7 +127,7 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
|
|
|
143
127
|
// No auditCtx → un-audited framework-internal read (every webhook would
|
|
144
128
|
// otherwise spam the audit trail). This is the sanctioned no-ctx path.
|
|
145
129
|
const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
|
|
146
|
-
const value = parseStoredSecret(got?.reveal());
|
|
130
|
+
const value = parseStoredSecret("subscription-stripe", got?.reveal());
|
|
147
131
|
if (value && value.length > 0) return value;
|
|
148
132
|
}
|
|
149
133
|
return fallback && fallback.length > 0 ? fallback : undefined;
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type EntityDefinition,
|
|
28
28
|
type FeatureRegistrar,
|
|
29
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
-
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.ts";
|
|
30
|
+
import { assertNoWhereRuleInOwnershipWrite, resolveFeatureAccess } from "../shared/index.ts";
|
|
31
31
|
import { createTagAssignmentEntity, tagEntity } from "./entity.ts";
|
|
32
32
|
import { createTagAssignmentExecutor } from "./executor.ts";
|
|
33
33
|
import { createAssignTagHandler } from "./handlers/assign-tag.write.ts";
|
|
@@ -175,12 +175,6 @@ export type TagsFeatureOptions = {
|
|
|
175
175
|
readonly parents?: readonly string[];
|
|
176
176
|
};
|
|
177
177
|
|
|
178
|
-
function resolveAccess(opts: TagsFeatureOptions): AccessRule {
|
|
179
|
-
if (opts.access !== undefined) return opts.access;
|
|
180
|
-
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
181
|
-
return DEFAULT_TAG_ACCESS;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
178
|
// Backwards-compat / options wrapper. Without options returns the module-level
|
|
185
179
|
// singleton (no rebuild). access/roles/toggleable/ownership build a fresh
|
|
186
180
|
// feature-definition.
|
|
@@ -203,7 +197,7 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof default
|
|
|
203
197
|
);
|
|
204
198
|
}
|
|
205
199
|
assertNoWhereRuleInOwnershipWrite("createTagsFeature", opts.ownership?.write);
|
|
206
|
-
const access =
|
|
200
|
+
const access = resolveFeatureAccess(opts, DEFAULT_TAG_ACCESS);
|
|
207
201
|
return defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
208
202
|
registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
|
|
209
203
|
);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "@cosmicdrift/kumiko-renderer";
|
|
18
18
|
import { pluralKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
19
19
|
import { type ReactNode, useState } from "react";
|
|
20
|
+
import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
|
|
20
21
|
import { TagsHandlers, TagsQueries } from "./constants.ts";
|
|
21
22
|
import { TagChip } from "./tag-chip.tsx";
|
|
22
23
|
|
|
@@ -117,8 +118,10 @@ export function TagManager({
|
|
|
117
118
|
const [editColor, setEditColor] = useState("");
|
|
118
119
|
const [editScope, setEditScope] = useState("");
|
|
119
120
|
const [deleting, setDeleting] = useState<TagRow | null>(null);
|
|
120
|
-
const
|
|
121
|
-
|
|
121
|
+
const refetch = async (): Promise<void> => {
|
|
122
|
+
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
123
|
+
};
|
|
124
|
+
const { busy, errorKey, setErrorKey, writeOk, apply } = useDispatchedWrites(refetch);
|
|
122
125
|
|
|
123
126
|
if (catalog.loading && catalog.data === null) {
|
|
124
127
|
return (
|
|
@@ -149,29 +152,6 @@ export function TagManager({
|
|
|
149
152
|
})
|
|
150
153
|
: allTags;
|
|
151
154
|
|
|
152
|
-
const refetch = async (): Promise<void> => {
|
|
153
|
-
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const apply = async (writes: () => Promise<boolean>): Promise<void> => {
|
|
157
|
-
setBusy(true);
|
|
158
|
-
setErrorKey(null);
|
|
159
|
-
try {
|
|
160
|
-
if (await writes()) await refetch();
|
|
161
|
-
} finally {
|
|
162
|
-
setBusy(false);
|
|
163
|
-
}
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
167
|
-
const result = await dispatcher.write(type, payload);
|
|
168
|
-
if (!result.isSuccess) {
|
|
169
|
-
setErrorKey(result.error.i18nKey);
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
return true;
|
|
173
|
-
};
|
|
174
|
-
|
|
175
155
|
const createTag = (): void => {
|
|
176
156
|
const name = newName.trim();
|
|
177
157
|
if (name === "") return;
|
|
@@ -11,13 +11,9 @@
|
|
|
11
11
|
// component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } }
|
|
12
12
|
// (RenderEdit passes { entityName, entityId }).
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
useDispatcher,
|
|
16
|
-
usePrimitives,
|
|
17
|
-
useQuery,
|
|
18
|
-
useTranslation,
|
|
19
|
-
} from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
20
15
|
import { type ReactNode, useState } from "react";
|
|
16
|
+
import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
|
|
21
17
|
import { TagsHandlers, TagsQueries } from "./constants.ts";
|
|
22
18
|
import { TagChip } from "./tag-chip.tsx";
|
|
23
19
|
import { TagPicker } from "./tag-picker.tsx";
|
|
@@ -53,7 +49,6 @@ export function TagSection({
|
|
|
53
49
|
}): ReactNode {
|
|
54
50
|
const { Banner, Button, Text } = usePrimitives();
|
|
55
51
|
const t = useTranslation();
|
|
56
|
-
const dispatcher = useDispatcher();
|
|
57
52
|
const enabled = entityId !== null;
|
|
58
53
|
const catalog = useQuery<TagListResponse>(TagsQueries.tagList, {}, { enabled });
|
|
59
54
|
// entityType server-side, not just entityId: it collapses the parent-ref
|
|
@@ -69,8 +64,7 @@ export function TagSection({
|
|
|
69
64
|
{ enabled },
|
|
70
65
|
);
|
|
71
66
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
72
|
-
const
|
|
73
|
-
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
67
|
+
const { busy, setBusy, errorKey, setErrorKey, writeOk } = useDispatchedWrites();
|
|
74
68
|
|
|
75
69
|
if (entityId === null) {
|
|
76
70
|
return (
|
|
@@ -105,15 +99,6 @@ export function TagSection({
|
|
|
105
99
|
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
106
100
|
};
|
|
107
101
|
|
|
108
|
-
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
109
|
-
const result = await dispatcher.write(type, payload);
|
|
110
|
-
if (!result.isSuccess) {
|
|
111
|
-
setErrorKey(result.error.i18nKey);
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
return true;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
102
|
const onPicked = (next: readonly string[]): void => {
|
|
118
103
|
const { added, removed } = tagSelectionDelta(assignedIds, next);
|
|
119
104
|
if (added.length === 0 && removed.length === 0) return;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
MAX_LIST_LIMIT,
|
|
5
5
|
SYSTEM_TENANT_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { chunk } from "@cosmicdrift/kumiko-framework/utils";
|
|
7
8
|
import * as z from "zod";
|
|
8
9
|
import { TenantQueries } from "../../tenant/index.ts";
|
|
9
10
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
|
|
@@ -22,14 +23,6 @@ const directorySchema = z.object({
|
|
|
22
23
|
|
|
23
24
|
type OverrideStatus = "active" | "draft";
|
|
24
25
|
|
|
25
|
-
function chunksOf<T>(items: readonly T[], size: number): T[][] {
|
|
26
|
-
const chunks: T[][] = [];
|
|
27
|
-
for (let start = 0; start < items.length; start += size) {
|
|
28
|
-
chunks.push(items.slice(start, start + size));
|
|
29
|
-
}
|
|
30
|
-
return chunks;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
26
|
type DirectoryQueryContext = {
|
|
34
27
|
readonly dispatch: {
|
|
35
28
|
readonly query: (name: string, payload: { ids: string[] }) => Promise<unknown>;
|
|
@@ -43,8 +36,8 @@ async function labelsFor(
|
|
|
43
36
|
ids: ReadonlySet<string>,
|
|
44
37
|
): Promise<ReadonlyMap<string, string>> {
|
|
45
38
|
const labels = new Map<string, string>();
|
|
46
|
-
for (const
|
|
47
|
-
const raw = await ctx.dispatch.query(queryName, { ids:
|
|
39
|
+
for (const idChunk of chunk([...ids], MAX_LIST_LIMIT)) {
|
|
40
|
+
const raw = await ctx.dispatch.query(queryName, { ids: idChunk });
|
|
48
41
|
for (const row of directorySchema.parse(raw).rows) labels.set(row.id, row.label);
|
|
49
42
|
}
|
|
50
43
|
return labels;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
|
|
2
|
+
|
|
3
|
+
export type MemberUserRow = {
|
|
4
|
+
readonly id: unknown;
|
|
5
|
+
readonly email?: unknown;
|
|
6
|
+
readonly displayName?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
|
|
10
|
+
// own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
|
|
11
|
+
// one call per user unbounded and exhausts the pool once membership counts
|
|
12
|
+
// exceed a handful, surfacing as "the connection was closed"; a strict
|
|
13
|
+
// sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
|
|
14
|
+
export const KMS_POOL_CONCURRENCY = 4;
|
|
15
|
+
|
|
16
|
+
export async function decryptMemberUsers(
|
|
17
|
+
users: readonly MemberUserRow[],
|
|
18
|
+
reason: string,
|
|
19
|
+
): Promise<Map<string, { readonly email: string | null; readonly displayName: string | null }>> {
|
|
20
|
+
const decrypted = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
|
|
21
|
+
const email =
|
|
22
|
+
typeof user.email === "string" ? await decryptStoredPii(user.email, "email", reason) : null;
|
|
23
|
+
const displayName =
|
|
24
|
+
typeof user.displayName === "string"
|
|
25
|
+
? await decryptStoredPii(user.displayName, "displayName", reason)
|
|
26
|
+
: null;
|
|
27
|
+
return { userId: String(user.id), email, displayName };
|
|
28
|
+
});
|
|
29
|
+
return new Map(decrypted.map((d) => [d.userId, d]));
|
|
30
|
+
}
|
|
@@ -3,18 +3,9 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import * as z from "zod";
|
|
6
|
-
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
|
|
7
6
|
import { userTable } from "../../user/index.ts";
|
|
8
7
|
import { tenantMembershipsTable } from "../membership-table.ts";
|
|
9
|
-
|
|
10
|
-
type UserRow = { readonly id: unknown; readonly email?: unknown; readonly displayName?: unknown };
|
|
11
|
-
|
|
12
|
-
// Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
|
|
13
|
-
// own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
|
|
14
|
-
// one call per user unbounded and exhausts the pool once membership counts
|
|
15
|
-
// exceed a handful, surfacing as "the connection was closed"; a strict
|
|
16
|
-
// sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
|
|
17
|
-
const KMS_POOL_CONCURRENCY = 4;
|
|
8
|
+
import { decryptMemberUsers, type MemberUserRow } from "./decrypt-member-users.ts";
|
|
18
9
|
|
|
19
10
|
export const membersQuery = defineQueryHandler({
|
|
20
11
|
name: "members",
|
|
@@ -43,21 +34,10 @@ export const membersQuery = defineQueryHandler({
|
|
|
43
34
|
|
|
44
35
|
const userIds = [...new Set(rows.map((row) => row["userId"]))];
|
|
45
36
|
const users =
|
|
46
|
-
userIds.length > 0 ? await selectMany<
|
|
37
|
+
userIds.length > 0 ? await selectMany<MemberUserRow>(db, userTable, { id: userIds }) : [];
|
|
47
38
|
const userById = new Map(users.map((u) => [String(u.id), u]));
|
|
48
39
|
|
|
49
|
-
const
|
|
50
|
-
const email =
|
|
51
|
-
typeof user.email === "string"
|
|
52
|
-
? await decryptStoredPii(user.email, "email", "tenant:members")
|
|
53
|
-
: null;
|
|
54
|
-
const displayName =
|
|
55
|
-
typeof user.displayName === "string"
|
|
56
|
-
? await decryptStoredPii(user.displayName, "displayName", "tenant:members")
|
|
57
|
-
: null;
|
|
58
|
-
return { userId: String(user.id), email, displayName };
|
|
59
|
-
});
|
|
60
|
-
const decryptedByUserId = new Map(decrypted.map((d) => [d.userId, d]));
|
|
40
|
+
const decryptedByUserId = await decryptMemberUsers(users, "tenant:members");
|
|
61
41
|
|
|
62
42
|
return rows.map((row) => {
|
|
63
43
|
const user = userById.get(String(row["userId"]));
|
|
@@ -14,8 +14,11 @@ import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
|
|
|
14
14
|
import { userTable } from "../../user/index.ts";
|
|
15
15
|
import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table.ts";
|
|
16
16
|
import { tenantMembershipsTable } from "../membership-table.ts";
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
import {
|
|
18
|
+
decryptMemberUsers,
|
|
19
|
+
KMS_POOL_CONCURRENCY,
|
|
20
|
+
type MemberUserRow,
|
|
21
|
+
} from "./decrypt-member-users.ts";
|
|
19
22
|
|
|
20
23
|
type TeamStatus = "active" | "pending";
|
|
21
24
|
|
|
@@ -115,11 +118,7 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
115
118
|
const membershipRows = await selectMany(db, tenantMembershipsTable, { tenantId });
|
|
116
119
|
const userIds = [...new Set(membershipRows.map((row) => String(row["userId"])))];
|
|
117
120
|
const users =
|
|
118
|
-
userIds.length > 0
|
|
119
|
-
? await selectMany<{ id: unknown; email?: unknown; displayName?: unknown }>(db, userTable, {
|
|
120
|
-
id: userIds,
|
|
121
|
-
})
|
|
122
|
-
: [];
|
|
121
|
+
userIds.length > 0 ? await selectMany<MemberUserRow>(db, userTable, { id: userIds }) : [];
|
|
123
122
|
|
|
124
123
|
const lastSeenByUserId = new Map<string, Temporal.Instant>();
|
|
125
124
|
if (userIds.length > 0) {
|
|
@@ -147,21 +146,10 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
147
146
|
}
|
|
148
147
|
|
|
149
148
|
// Bounded, not Promise.all/sequential-per-item: shares the KMS
|
|
150
|
-
// adapter's small dedicated pool (max 4, see
|
|
149
|
+
// adapter's small dedicated pool (max 4, see decrypt-member-users.ts) with the
|
|
151
150
|
// invitations decrypt below — the two passes run one after the other
|
|
152
151
|
// (await, not Promise.all), so combined concurrency never exceeds 4.
|
|
153
|
-
const
|
|
154
|
-
const email =
|
|
155
|
-
typeof user.email === "string"
|
|
156
|
-
? await decryptStoredPii(user.email, "email", "tenant:team-list")
|
|
157
|
-
: null;
|
|
158
|
-
const displayName =
|
|
159
|
-
typeof user.displayName === "string"
|
|
160
|
-
? await decryptStoredPii(user.displayName, "displayName", "tenant:team-list")
|
|
161
|
-
: null;
|
|
162
|
-
return { userId: String(user.id), email, displayName };
|
|
163
|
-
});
|
|
164
|
-
const decryptedByUserId = new Map(decryptedUsers.map((d) => [d.userId, d]));
|
|
152
|
+
const decryptedByUserId = await decryptMemberUsers(users, "tenant:team-list");
|
|
165
153
|
|
|
166
154
|
const memberRows: TeamRow[] = membershipRows.map((row) => {
|
|
167
155
|
const userId = String(row["userId"]);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
|
|
5
|
+
export const TENANT_DESTROY_ARCHIVED_BY = "tenant-lifecycle:destroy";
|
|
6
|
+
export const TENANT_DESTROY_ARCHIVE_REASON = "tenant_destroy";
|
|
7
|
+
|
|
8
|
+
// Archiving first means a later projection rebuild cannot resurrect the deleted rows.
|
|
9
|
+
export async function archiveAndDeleteTenantRows(
|
|
10
|
+
ctx: TenantDataHookCtx,
|
|
11
|
+
table: EntityTableMeta,
|
|
12
|
+
aggregateType: string,
|
|
13
|
+
escapeHatchReason: string,
|
|
14
|
+
): Promise<readonly string[]> {
|
|
15
|
+
declareEscapeHatch({ reason: escapeHatchReason });
|
|
16
|
+
const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
await archiveStream(ctx.db.unsafeRaw(), {
|
|
19
|
+
tenantId: ctx.tenantId,
|
|
20
|
+
aggregateId: row.id,
|
|
21
|
+
aggregateType,
|
|
22
|
+
archivedBy: TENANT_DESTROY_ARCHIVED_BY,
|
|
23
|
+
reason: TENANT_DESTROY_ARCHIVE_REASON,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
await ctx.db.deleteMany(table, { tenantId: ctx.tenantId });
|
|
27
|
+
return rows.map((row) => row.id);
|
|
28
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.405.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "archiveAndDeleteTenantRows for tenant-destroy hooks",
|
|
6
|
+
"detail": "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle exports archiveAndDeleteTenantRows(ctx, table, aggregateType, escapeHatchReason), which archives each row's event stream and then deletes the tenant's rows, plus the TENANT_DESTROY_ARCHIVED_BY and TENANT_DESTROY_ARCHIVE_REASON constants. inbound-mail-foundation uses the helper, billing-foundation the constants."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.389.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -495,7 +495,7 @@ export function createTierEngineFeature<
|
|
|
495
495
|
// point AND registers itself as the default plugin. Other features could
|
|
496
496
|
// register there later (e.g. a future toggle-only resolver), but only one
|
|
497
497
|
// plugin wins — see the "single-plugin" comment on findTierResolverUsage.
|
|
498
|
-
r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, {
|
|
498
|
+
r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, {});
|
|
499
499
|
|
|
500
500
|
// Plugin-registration: framework's runDevApp/runProdApp picks this up
|
|
501
501
|
// post-stack-setup, calls build(deps), wires effectiveFeatures.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createTenantDb, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
type EscapeHatchAuditSink,
|
|
4
|
+
EXT_USER_DATA,
|
|
5
|
+
type TenantId,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
8
|
+
import {
|
|
9
|
+
createEscapeHatchReporter,
|
|
10
|
+
UNATTRIBUTED_ACTOR,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
12
|
+
|
|
13
|
+
export function buildHookDb(
|
|
14
|
+
db: DbRunner,
|
|
15
|
+
tenantId: TenantId,
|
|
16
|
+
entry: { readonly entityName: string; readonly escapeHatchReason: string | undefined },
|
|
17
|
+
args: {
|
|
18
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
19
|
+
readonly escapeHatchAuditLog?: Logger;
|
|
20
|
+
readonly actor?: string;
|
|
21
|
+
},
|
|
22
|
+
) {
|
|
23
|
+
const report = createEscapeHatchReporter({
|
|
24
|
+
handler: `${EXT_USER_DATA}:${entry.entityName}`,
|
|
25
|
+
tenantId,
|
|
26
|
+
actor: args.actor ?? UNATTRIBUTED_ACTOR,
|
|
27
|
+
sink: args.escapeHatchAuditSink,
|
|
28
|
+
log: args.escapeHatchAuditLog ?? createFallbackLogger("user-data-rights"),
|
|
29
|
+
});
|
|
30
|
+
return createTenantDb({
|
|
31
|
+
db,
|
|
32
|
+
tenantId,
|
|
33
|
+
mode: "tenant",
|
|
34
|
+
grants: {
|
|
35
|
+
unsafeRaw:
|
|
36
|
+
entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
|
|
37
|
+
report,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
25
25
|
import { defineWriteHandler, type SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
-
import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
27
26
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
28
27
|
import * as z from "zod";
|
|
28
|
+
import { isUniqueConstraintFailure } from "../../shared/index.ts";
|
|
29
29
|
import {
|
|
30
30
|
ACTIVE_JOB_CONSTRAINT,
|
|
31
31
|
EXPORT_JOB_STATUS,
|
|
@@ -40,23 +40,6 @@ const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
|
|
|
40
40
|
const REQUEST_EXPORT_REASON =
|
|
41
41
|
"export jobs are keyed by userId across all of the user's tenant memberships, not the caller's current tenant";
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* `createEventStoreExecutor.create` catches 23505 internally and returns
|
|
45
|
-
* `WriteFailure(UniqueViolationError)` rather than throwing. Check the
|
|
46
|
-
* failure code and constraintName to distinguish the app-side and race
|
|
47
|
-
* paths; propagate other failures (validation, version conflicts, ...)
|
|
48
|
-
* unchanged.
|
|
49
|
-
*/
|
|
50
|
-
function isActiveJobConflict(failure: WriteFailure): boolean {
|
|
51
|
-
const error = failure.error as {
|
|
52
|
-
code?: string;
|
|
53
|
-
details?: { constraintName?: string };
|
|
54
|
-
}; // @cast-boundary error-details
|
|
55
|
-
return (
|
|
56
|
-
error.code === "unique_violation" && error.details?.constraintName === ACTIVE_JOB_CONSTRAINT
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
43
|
export const requestExportWrite = defineWriteHandler({
|
|
61
44
|
name: "request-export",
|
|
62
45
|
schema: z.object({}),
|
|
@@ -115,7 +98,7 @@ export const requestExportWrite = defineWriteHandler({
|
|
|
115
98
|
// crud.create, causing UniqueViolationError for our constraint. Re-fetch
|
|
116
99
|
// and return the existing job with isExisting=true. Propagate other
|
|
117
100
|
// failures (validation, version conflicts, ...) unchanged.
|
|
118
|
-
if (
|
|
101
|
+
if (isUniqueConstraintFailure(result, ACTIVE_JOB_CONSTRAINT)) {
|
|
119
102
|
const winner = await findActiveJob(exportJobRunner, userId);
|
|
120
103
|
if (!winner) {
|
|
121
104
|
// This should be impossible: a constraint violation without an existing
|
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type FeatureRegistrar,
|
|
3
|
-
type NotificationLocalizedTemplate,
|
|
4
3
|
QnTypes,
|
|
5
4
|
qn,
|
|
6
5
|
type TemplateVariables,
|
|
7
6
|
templateVariable,
|
|
8
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import {
|
|
10
|
-
hasMailTranslationKey,
|
|
11
|
-
listMailLocales,
|
|
12
|
-
mailT,
|
|
13
|
-
resolveMailLocale,
|
|
14
|
-
} from "@cosmicdrift/kumiko-framework/i18n";
|
|
8
|
+
import { mailT } from "@cosmicdrift/kumiko-framework/i18n";
|
|
15
9
|
import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
16
|
-
import { mailParagraph } from "../shared/index.ts";
|
|
10
|
+
import { createMailLocaleTemplates, mailParagraph } from "../shared/index.ts";
|
|
17
11
|
import { USER_DATA_RIGHTS_FEATURE } from "./web/constants.ts";
|
|
18
12
|
|
|
19
13
|
const ALL_GDPR_MAIL_KINDS = [
|
|
@@ -49,18 +43,14 @@ function subjectKey(kind: GdprMailKind): string {
|
|
|
49
43
|
return `gdpr.mail.${GDPR_MAIL_KINDS[kind].translationKey}.subject`;
|
|
50
44
|
}
|
|
51
45
|
|
|
52
|
-
const
|
|
53
|
-
ALL_GDPR_MAIL_KINDS
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
export
|
|
60
|
-
const locale = resolveMailLocale(requestedLocale);
|
|
61
|
-
const key = SUBJECT_KEY_BY_TYPE.get(notificationType);
|
|
62
|
-
return key !== undefined && hasMailTranslationKey(locale, key) ? locale : "en";
|
|
63
|
-
}
|
|
46
|
+
const mailLocaleTemplates = createMailLocaleTemplates({
|
|
47
|
+
kinds: ALL_GDPR_MAIL_KINDS,
|
|
48
|
+
notificationTypes: GdprMailNotificationTypes,
|
|
49
|
+
subjectKey,
|
|
50
|
+
bodyFor: (kind, locale) => mailBody(kind, locale),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const gdprMailTemplateLocale = mailLocaleTemplates.templateLocale;
|
|
64
54
|
|
|
65
55
|
const appName = () => templateVariable.text({ required: true });
|
|
66
56
|
|
|
@@ -119,33 +109,7 @@ function mailBody(kind: GdprMailKind, locale: string): string {
|
|
|
119
109
|
}
|
|
120
110
|
}
|
|
121
111
|
|
|
122
|
-
|
|
123
|
-
// registered its mail translations. English always ships; another locale only
|
|
124
|
-
// gets a template when it has its own subject, so a missing translation never
|
|
125
|
-
// silently becomes an English mail in that locale's slot.
|
|
126
|
-
export function gdprMailLocalizedTemplate(kind: GdprMailKind): () => NotificationLocalizedTemplate {
|
|
127
|
-
const kindSubjectKey = subjectKey(kind);
|
|
128
|
-
return () => {
|
|
129
|
-
const locales = [
|
|
130
|
-
"en",
|
|
131
|
-
...listMailLocales().filter(
|
|
132
|
-
(locale) => locale !== "en" && hasMailTranslationKey(locale, kindSubjectKey),
|
|
133
|
-
),
|
|
134
|
-
];
|
|
135
|
-
return Object.fromEntries(
|
|
136
|
-
locales.map((locale) => [
|
|
137
|
-
locale,
|
|
138
|
-
{
|
|
139
|
-
body: mailBody(kind, locale),
|
|
140
|
-
meta: {
|
|
141
|
-
subject: mailT(locale, kindSubjectKey, { app: "{{appName}}" }),
|
|
142
|
-
category: "transactional" as const,
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
]),
|
|
146
|
-
);
|
|
147
|
-
};
|
|
148
|
-
}
|
|
112
|
+
export const gdprMailLocalizedTemplate = mailLocaleTemplates.localizedTemplate;
|
|
149
113
|
|
|
150
114
|
export function registerGdprMails(r: FeatureRegistrar): void {
|
|
151
115
|
for (const kind of ALL_GDPR_MAIL_KINDS) {
|