@cosmicdrift/kumiko-samples 0.404.0 → 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 +25 -25
- package/packages/bundled-features/src/account-security/README.md +7 -5
- 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/auth-gate.tsx +4 -0
- 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/documents/changes.json +8 -0
- 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/host-routing/README.md +10 -10
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
- 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/legal-pages/README.md +21 -13
- 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/README.md +6 -4
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
- 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/README.md +29 -29
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
- 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/tenant-lifecycle/stages.ts +1 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
- package/packages/bundled-features/src/user-data-rights/README.md +33 -33
- 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-data-rights/web/public-deletion-gate.tsx +1 -0
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
- package/samples/apps/admin-console/README.md +5 -5
- package/samples/apps/admin-console/package.json +3 -3
- package/samples/apps/cap-billing-demo/README.md +15 -15
- package/samples/apps/config-demo/README.md +7 -7
- package/samples/apps/config-demo/package.json +2 -2
- package/samples/apps/marketing-demo/README.md +2 -2
- package/samples/apps/marketing-demo/package.json +2 -2
- package/samples/apps/showcase/package.json +3 -3
- package/samples/apps/styleguide/package.json +2 -2
- package/samples/apps/ui-walkthrough/README.md +8 -9
- package/samples/apps/ui-walkthrough/package.json +2 -2
- package/samples/apps/use-all-bundled/README.md +2 -2
- package/samples/apps/use-all-bundled/package.json +2 -2
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
- package/samples/apps/user-data-rights-demo/README.md +12 -12
- package/samples/apps/workspaces/package.json +2 -2
- package/samples/recipes/apex-landing/README.md +3 -3
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-surface-auth/README.md +13 -9
- package/samples/recipes/auth-mfa/README.md +2 -4
- package/samples/recipes/auth-mfa/package.json +2 -2
- package/samples/recipes/boot-check/README.md +2 -2
- package/samples/recipes/compliance-profiles-demo/README.md +5 -5
- package/samples/recipes/compose-stacks/README.md +8 -8
- package/samples/recipes/cross-feature-events/README.md +1 -0
- package/samples/recipes/crypto-shredding-hr/README.md +3 -3
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/documents/README.md +25 -0
- package/samples/recipes/embedded-entity-form/package.json +2 -2
- package/samples/recipes/encrypted-tenant-config/README.md +8 -8
- package/samples/recipes/env-composition/README.md +10 -10
- package/samples/recipes/error-contract/README.md +9 -9
- package/samples/recipes/event-sourcing/README.md +9 -9
- package/samples/recipes/ledger-invoicing/README.md +6 -6
- package/samples/recipes/legal-pages/README.md +16 -16
- package/samples/recipes/managed-config/README.md +15 -11
- package/samples/recipes/managed-pages/README.md +7 -7
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pipeline-basics/README.md +13 -13
- package/samples/recipes/public-share-token/README.md +4 -4
- package/samples/recipes/record-detail-layout/package.json +3 -3
- package/samples/recipes/security-baseline/README.md +4 -1
- package/samples/recipes/session-revocation/README.md +1 -1
- package/samples/recipes/tenant-settings/README.md +4 -4
- package/samples/recipes/tier-admin/README.md +2 -2
- package/samples/recipes/user-data-rights/README.md +21 -19
- package/samples/recipes/wizard-form/package.json +3 -3
- package/samples/recipes/writeform-section/package.json +3 -3
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// cache misses, cached per price id.
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
createTtlPriceCache,
|
|
5
6
|
KNOWN_RECURRING_INTERVALS,
|
|
6
7
|
type ProviderPrice,
|
|
8
|
+
type TtlPriceCache,
|
|
7
9
|
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
8
10
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { chunk } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
12
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
10
13
|
import * as z from "zod";
|
|
11
14
|
import type { PaddleCtxRuntime, PaddlePluginMethod } from "./runtime.ts";
|
|
@@ -61,10 +64,7 @@ function mapPaddlePrice(price: PaddlePrice): ProviderPrice {
|
|
|
61
64
|
return { ...base, interval, intervalCount: interval ? cycle.frequency : null };
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
export type PaddlePriceCache =
|
|
65
|
-
readonly get: (priceId: string) => ProviderPrice | undefined;
|
|
66
|
-
readonly set: (priceId: string, price: ProviderPrice) => void;
|
|
67
|
-
};
|
|
67
|
+
export type PaddlePriceCache = TtlPriceCache<ProviderPrice>;
|
|
68
68
|
|
|
69
69
|
export type PaddlePriceCacheOptions = {
|
|
70
70
|
readonly ttlMs?: number;
|
|
@@ -75,32 +75,10 @@ export type PaddlePriceCacheOptions = {
|
|
|
75
75
|
// trigger an audited secret read on every billing-plans render. Failed lookups
|
|
76
76
|
// are never cached.
|
|
77
77
|
export function createPaddlePriceCache({
|
|
78
|
-
ttlMs
|
|
78
|
+
ttlMs,
|
|
79
79
|
now = () => Temporal.Now.instant().epochMilliseconds,
|
|
80
80
|
}: PaddlePriceCacheOptions = {}): PaddlePriceCache {
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
get: (priceId) => {
|
|
84
|
-
const entry = store.get(priceId);
|
|
85
|
-
if (!entry) return undefined;
|
|
86
|
-
if (entry.expiresAt <= now()) {
|
|
87
|
-
store.delete(priceId);
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
return entry.price;
|
|
91
|
-
},
|
|
92
|
-
set: (priceId, price) => {
|
|
93
|
-
store.set(priceId, { price, expiresAt: now() + ttlMs });
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function chunked<T>(items: readonly T[], size: number): T[][] {
|
|
99
|
-
const chunks: T[][] = [];
|
|
100
|
-
for (let start = 0; start < items.length; start += size) {
|
|
101
|
-
chunks.push(items.slice(start, start + size));
|
|
102
|
-
}
|
|
103
|
-
return chunks;
|
|
81
|
+
return createTtlPriceCache<ProviderPrice>({ ttlMs, now });
|
|
104
82
|
}
|
|
105
83
|
|
|
106
84
|
async function fillPriceCache(
|
|
@@ -111,7 +89,7 @@ async function fillPriceCache(
|
|
|
111
89
|
): Promise<void> {
|
|
112
90
|
const misses = [...new Set(priceIds.filter((priceId) => cache.get(priceId) === undefined))];
|
|
113
91
|
const settled = await Promise.allSettled(
|
|
114
|
-
|
|
92
|
+
chunk(misses, PRICES_PER_REQUEST).map(async (ids) =>
|
|
115
93
|
pricesResponseSchema.parse(
|
|
116
94
|
await runtime.call(ctx, {
|
|
117
95
|
method: "GET",
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// HandlerContext; the pre-tenant webhook reads secrets un-audited from the
|
|
8
8
|
// system SecretsContext the foundation passes in.
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
parseStoredSecret,
|
|
12
|
+
type SubscriptionProviderPlugin,
|
|
13
|
+
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
11
14
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
12
15
|
import {
|
|
13
16
|
type ConfigKeyHandle,
|
|
@@ -16,7 +19,6 @@ import {
|
|
|
16
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
20
|
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
18
21
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
19
|
-
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
20
22
|
import { createPaddleHttp, type PaddleRequest, type PaddleSend } from "./client.ts";
|
|
21
23
|
import {
|
|
22
24
|
PADDLE_BASE_URLS,
|
|
@@ -47,19 +49,6 @@ export type PaddleRuntimeDeps = {
|
|
|
47
49
|
readonly fallback: PaddleFallbacks;
|
|
48
50
|
};
|
|
49
51
|
|
|
50
|
-
// backing:"secrets" values are stored JSON-serialized, so the store read has to
|
|
51
|
-
// be parsed back; the env fallback is a plain string and stays raw. The error
|
|
52
|
-
// must not carry the input: the JSON parser's own message would repeat the
|
|
53
|
-
// credential into logs and, for webhooks, into the 401 body.
|
|
54
|
-
function parseStoredSecret(raw: string | undefined): string | undefined {
|
|
55
|
-
if (raw === undefined || raw.length === 0) return undefined;
|
|
56
|
-
const parsed = parseJsonSafe<unknown>(raw, undefined);
|
|
57
|
-
if (parsed === undefined) {
|
|
58
|
-
throw new Error("subscription-paddle credential (backing:secrets) is not valid JSON");
|
|
59
|
-
}
|
|
60
|
-
return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
52
|
function hintFor(shortName: string): string {
|
|
64
53
|
return `Set it via config:write:set on \`${SUBSCRIPTION_PADDLE_FEATURE}:config:${shortName}\` (or seed it from the matching PADDLE_* variable during the env bridge).`;
|
|
65
54
|
}
|
|
@@ -108,7 +97,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
|
|
|
108
97
|
{ secrets: ctx.services.secrets, user: ctx.user },
|
|
109
98
|
SUBSCRIPTION_PADDLE_FEATURE,
|
|
110
99
|
).get(SYSTEM_TENANT_ID, handle.name);
|
|
111
|
-
value = parseStoredSecret(got?.reveal());
|
|
100
|
+
value = parseStoredSecret("subscription-paddle", got?.reveal());
|
|
112
101
|
}
|
|
113
102
|
if (!value && fallback && fallback.length > 0) value = fallback;
|
|
114
103
|
if (!value) {
|
|
@@ -129,7 +118,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
|
|
|
129
118
|
if (systemSecrets) {
|
|
130
119
|
// No auditCtx: every webhook would otherwise spam the audit trail.
|
|
131
120
|
const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
|
|
132
|
-
const stored = parseStoredSecret(got?.reveal());
|
|
121
|
+
const stored = parseStoredSecret("subscription-paddle", got?.reveal());
|
|
133
122
|
if (stored) return stored;
|
|
134
123
|
}
|
|
135
124
|
if (fallback && fallback.length > 0) return fallback;
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
// api key + webhook secret without auditing (a sanctioned framework-internal
|
|
33
33
|
// read).
|
|
34
34
|
|
|
35
|
-
import
|
|
35
|
+
import {
|
|
36
|
+
type SubscriptionProviderPlugin,
|
|
37
|
+
validateOneOffPriceIds,
|
|
38
|
+
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
36
39
|
import {
|
|
37
40
|
access,
|
|
38
41
|
createSystemConfig,
|
|
@@ -144,23 +147,6 @@ export type SubscriptionStripeOptions = {
|
|
|
144
147
|
readonly oneOffPriceIds?: readonly string[];
|
|
145
148
|
};
|
|
146
149
|
|
|
147
|
-
// Validated once at feature-build time (not per-request) so a config typo
|
|
148
|
-
// (a pasted-twice priceId, a blank string from a bad env-join) fails app
|
|
149
|
-
// startup instead of silently widening — or narrowing — the one-off
|
|
150
|
-
// allowlist at request time. Mirrors legal-pages' validateRoutes.
|
|
151
|
-
function validateOneOffPriceIds(priceIds: readonly string[]): void {
|
|
152
|
-
const seen = new Set<string>();
|
|
153
|
-
for (const priceId of priceIds) {
|
|
154
|
-
if (priceId.length === 0) {
|
|
155
|
-
throw new Error("subscription-stripe: oneOffPriceIds must not contain an empty string");
|
|
156
|
-
}
|
|
157
|
-
if (seen.has(priceId)) {
|
|
158
|
-
throw new Error(`subscription-stripe: duplicate oneOffPriceIds entry "${priceId}"`);
|
|
159
|
-
}
|
|
160
|
-
seen.add(priceId);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
150
|
/**
|
|
165
151
|
* Factory for the subscription-stripe feature. Always mounts (no
|
|
166
152
|
* key-presence guard) — activity is gated at runtime through config/secrets
|
|
@@ -171,7 +157,7 @@ function validateOneOffPriceIds(priceIds: readonly string[]): void {
|
|
|
171
157
|
export function createSubscriptionStripeFeature(
|
|
172
158
|
options: SubscriptionStripeOptions = {},
|
|
173
159
|
): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionStripeEnvSchema.shape> {
|
|
174
|
-
validateOneOffPriceIds(options.oneOffPriceIds ?? []);
|
|
160
|
+
validateOneOffPriceIds("subscription-stripe", options.oneOffPriceIds ?? []);
|
|
175
161
|
const feature: FeatureDefinition = defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
|
|
176
162
|
r.describe(
|
|
177
163
|
'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog), `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), `retrieveSubscription` (live provider-side snapshot for the `sync-subscriptions` backfill job, mapped identically to the webhook path), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',
|
|
@@ -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;
|
|
@@ -44,13 +44,13 @@ async function someHandler(ctx) {
|
|
|
44
44
|
|
|
45
45
|
## Kinds, format, and meta
|
|
46
46
|
|
|
47
|
-
| kind
|
|
48
|
-
|
|
49
|
-
| `mail`
|
|
50
|
-
| `mail-layout`
|
|
51
|
-
| `pdf-layout`
|
|
52
|
-
| `document-pdf`
|
|
53
|
-
| remaining kinds | arbitrary
|
|
47
|
+
| kind | contentFormat | meta |
|
|
48
|
+
| --------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `mail` | `html` (document schema, rebuilt server-side) | Required: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
|
|
50
|
+
| `mail-layout` | `mjml` | empty (`{}`) or missing |
|
|
51
|
+
| `pdf-layout` | `html` | empty (`{}`) or missing |
|
|
52
|
+
| `document-pdf` | `html` | missing |
|
|
53
|
+
| remaining kinds | arbitrary | missing |
|
|
54
54
|
|
|
55
55
|
`meta` is stored as JSON in the `meta` column and is validated on write per kind against
|
|
56
56
|
`template-meta.ts` (errors as field issues on `meta.*`). Subject and preview text allow only simple
|
|
@@ -105,22 +105,22 @@ await seedSystemTemplate(db, {
|
|
|
105
105
|
|
|
106
106
|
## Admin workflows (write handlers + queries)
|
|
107
107
|
|
|
108
|
-
| Handler
|
|
109
|
-
|
|
110
|
-
| `TemplateResolverHandlers.upsertSystem`
|
|
111
|
-
| `TemplateResolverHandlers.upsertTenant`
|
|
112
|
-
| `TemplateResolverHandlers.publish`
|
|
113
|
-
| `TemplateResolverHandlers.archive`
|
|
114
|
-
| `TemplateResolverHandlers.publishSystem`
|
|
115
|
-
| `TemplateResolverHandlers.archiveSystem`
|
|
116
|
-
| `TemplateResolverHandlers.resetToSystem`
|
|
117
|
-
| `TemplateResolverQueries.findById`
|
|
118
|
-
| `TemplateResolverQueries.list`
|
|
119
|
-
| `TemplateResolverQueries.usage`
|
|
120
|
-
| `TemplateResolverQueries.overview`
|
|
121
|
-
| `TemplateResolverQueries.layoutSource`
|
|
122
|
-
| `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin
|
|
123
|
-
| `TemplateResolverQueries.diffToSystem`
|
|
108
|
+
| Handler | QN | Who | What |
|
|
109
|
+
| ----------------------------------------- | ------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Creates or updates system default templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
|
|
111
|
+
| `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (own tenant) + SystemAdmin via `tenantIdOverride` | Creates or updates tenant overrides (scope='tenant'), default status='draft' |
|
|
112
|
+
| `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (own tenant) | Sets status='active' |
|
|
113
|
+
| `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (own tenant) | Sets status='archived' (the resolver ignores it afterward) |
|
|
114
|
+
| `TemplateResolverHandlers.publishSystem` | `template-resolver:write:publish-system` | SystemAdmin | Sets status='active' on a system default, with the same mail validation as `publish` |
|
|
115
|
+
| `TemplateResolverHandlers.archiveSystem` | `template-resolver:write:archive-system` | SystemAdmin | Sets status='archived' on a system default; tenants without an override fall through |
|
|
116
|
+
| `TemplateResolverHandlers.resetToSystem` | `template-resolver:write:reset-to-system` | TenantAdmin (own tenant) | Archives the own override so that the active system template applies again; without an active system template, `no_system_template` |
|
|
117
|
+
| `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (own tenant, system templates visible) | Raw lookup for the edit UI |
|
|
118
|
+
| `TemplateResolverQueries.list` | `template-resolver:query:list` | same | Filters by kind/locale/status, optionally includeSystem |
|
|
119
|
+
| `TemplateResolverQueries.usage` | `template-resolver:query:usage` | TenantAdmin | Which notifications use a mail template or mail layout, from the registry |
|
|
120
|
+
| `TemplateResolverQueries.overview` | `template-resolver:query:overview` | TenantAdmin + SystemAdmin | One row per slug and type, mail, PDF, layouts, with origin, languages, status, version, layout, and usage; searchable, filterable, sortable, and pageable |
|
|
121
|
+
| `TemplateResolverQueries.layoutSource` | `template-resolver:query:layout-source` | TenantAdmin + SystemAdmin | Own and system version of a layout, header/signature/footer regions of the effective HTML version, storage locale; MJML source only for SystemAdmin |
|
|
122
|
+
| `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin | Number of active/draft overrides of a system template and total active tenants, numbers only |
|
|
123
|
+
| `TemplateResolverQueries.diffToSystem` | `template-resolver:query:diff-to-system` | TenantAdmin (own tenant) | Block and meta comparison of a tenant override with the system template |
|
|
124
124
|
|
|
125
125
|
**SystemAdmin cross-tenant access for publish/archive/findById:** deferred (Task 8), `upsertTenant` already has `tenantIdOverride`; publish/archive only need this when a system admin panel curates templates belonging to other tenants. `ctx.db` is tenant-scoped, foreign IDs result in NotFound without an override.
|
|
126
126
|
|
|
@@ -166,12 +166,12 @@ The harness checks `TemplateNotFoundError` propagation, locale fallback, and, wh
|
|
|
166
166
|
Marketing, FAQ, and legal texts use the same store, but a separate write path: `set.write` carries
|
|
167
167
|
`title` + `folder` (content tree) and has no draft status, saving publishes.
|
|
168
168
|
|
|
169
|
-
| Handler
|
|
170
|
-
|
|
171
|
-
| `TemplateResolverHandlers.set`
|
|
172
|
-
| `TemplateResolverQueries.bySlug`
|
|
173
|
-
| `TemplateResolverQueries.byTenant`
|
|
174
|
-
| `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | as declared
|
|
169
|
+
| Handler | QN | Who | What |
|
|
170
|
+
| -------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
171
|
+
| `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride`, also on `SYSTEM_TENANT_ID`) | Upserts one resource per `(tenantId, slug, kind, locale)` |
|
|
172
|
+
| `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | One text block, the public read for landing/legal pages |
|
|
173
|
+
| `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | All text blocks of a tenant for the content tree |
|
|
174
|
+
| `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | as declared | Per content collection, see below |
|
|
175
175
|
|
|
176
176
|
The two public queries are hardcoded to `kind: "text-block"` and accept **no** kind parameter. Mail
|
|
177
177
|
templates and AI prompts are stored in the same table and use the handlers of their collection. This
|
|
@@ -248,8 +248,9 @@ export function withParsedDocument<T>(html: string, read: (body: ScanElement) =>
|
|
|
248
248
|
);
|
|
249
249
|
return read(parsed.body);
|
|
250
250
|
} finally {
|
|
251
|
-
window.
|
|
252
|
-
window.happyDOM.
|
|
251
|
+
// The window is never reused, so this sync API need not await the teardown promises.
|
|
252
|
+
void window.happyDOM.abort();
|
|
253
|
+
void window.happyDOM.close();
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
256
|
|
|
@@ -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;
|
package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx
CHANGED
|
@@ -17,6 +17,7 @@ const SLUG_INVALID_ISSUE: FieldIssue = {
|
|
|
17
17
|
export function createCollectionEntryCreateForm(
|
|
18
18
|
tenantId: string | undefined,
|
|
19
19
|
): (props: { readonly target: TargetRef; readonly onClose: () => void }) => ReactNode {
|
|
20
|
+
// biome-ignore lint/correctness/noComponentHookFactories: setup-time factory, called once per templateResolverClient(); the resolver slot only passes target/onClose, so tenantId is bound here
|
|
20
21
|
return function BoundCollectionEntryCreateForm(props) {
|
|
21
22
|
return <CollectionEntryCreateForm {...props} tenantId={tenantId} />;
|
|
22
23
|
};
|
|
@@ -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"]));
|