@cosmicdrift/kumiko-bundled-features 0.1.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 +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DbConnection,
|
|
3
|
+
type EncryptionProvider,
|
|
4
|
+
fetchOne,
|
|
5
|
+
type TenantDb,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import type {
|
|
8
|
+
ConfigKeyDefinition,
|
|
9
|
+
ConfigResolver,
|
|
10
|
+
ConfigValueSource,
|
|
11
|
+
ConfigValueWithSource,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { assertUnreachable, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
15
|
+
import { and, eq, isNull, or } from "drizzle-orm";
|
|
16
|
+
import { configValuesTable } from "./table";
|
|
17
|
+
|
|
18
|
+
type ConfigRow = {
|
|
19
|
+
id: string;
|
|
20
|
+
key: string;
|
|
21
|
+
value: string | null;
|
|
22
|
+
tenantId: string;
|
|
23
|
+
userId: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Re-export so existing call sites that imported ConfigResolver from
|
|
27
|
+
// "../resolver" keep compiling — the shape now lives in the framework.
|
|
28
|
+
export type { ConfigResolver };
|
|
29
|
+
|
|
30
|
+
export function deserializeValue(
|
|
31
|
+
raw: string | null,
|
|
32
|
+
type: ConfigKeyDefinition["type"],
|
|
33
|
+
): string | number | boolean | undefined {
|
|
34
|
+
if (raw === null || raw === undefined) return undefined;
|
|
35
|
+
const parsed = parseJsonOrThrow<unknown>(raw, `config value (type=${type})`);
|
|
36
|
+
switch (type) {
|
|
37
|
+
case "number":
|
|
38
|
+
return typeof parsed === "number" ? parsed : Number(parsed);
|
|
39
|
+
case "boolean":
|
|
40
|
+
return typeof parsed === "boolean" ? parsed : parsed === "true";
|
|
41
|
+
case "text":
|
|
42
|
+
case "select":
|
|
43
|
+
return String(parsed);
|
|
44
|
+
default:
|
|
45
|
+
assertUnreachable(type, "config key type");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// App-Boot overrides: deploy-time defaults that sit between the
|
|
50
|
+
// tenant/system rows and the feature-declared `default`. Use-case: the
|
|
51
|
+
// framework ships a default of 10 MB for maxUploadSizeMB, but this
|
|
52
|
+
// particular deploy is a photo-app that wants 200 MB — no DB-seed needed,
|
|
53
|
+
// just a single Map at boot.
|
|
54
|
+
//
|
|
55
|
+
// Keys are the fully-qualified names (e.g. "files:config:max-upload-size-mb").
|
|
56
|
+
// Values are the raw primitive — serialization happens as part of the
|
|
57
|
+
// cascade so the boot-code doesn't need to know about JSON encoding.
|
|
58
|
+
//
|
|
59
|
+
// Validation at construction time (buildServer-path): unknown keys,
|
|
60
|
+
// type-mismatches, and bounds violations throw synchronously. See
|
|
61
|
+
// validateAppOverrides below.
|
|
62
|
+
export type AppConfigOverrides = ReadonlyMap<string, string | number | boolean>;
|
|
63
|
+
|
|
64
|
+
export type ConfigResolverOptions = {
|
|
65
|
+
encryption?: EncryptionProvider;
|
|
66
|
+
appOverrides?: AppConfigOverrides;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function createConfigResolver(options: ConfigResolverOptions = {}): ConfigResolver {
|
|
70
|
+
const { encryption, appOverrides } = options;
|
|
71
|
+
async function findRow(
|
|
72
|
+
key: string,
|
|
73
|
+
tenantId: string,
|
|
74
|
+
userId: string | null,
|
|
75
|
+
db: DbConnection | TenantDb,
|
|
76
|
+
): Promise<ConfigRow | null> {
|
|
77
|
+
const userCond =
|
|
78
|
+
userId !== null ? eq(configValuesTable.userId, userId) : isNull(configValuesTable.userId);
|
|
79
|
+
|
|
80
|
+
const row = await fetchOne<ConfigRow>(
|
|
81
|
+
db,
|
|
82
|
+
configValuesTable,
|
|
83
|
+
eq(configValuesTable.key, key),
|
|
84
|
+
eq(configValuesTable.tenantId, tenantId),
|
|
85
|
+
userCond,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return row ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
async get(qualifiedKey, keyDef, tenantId, userId, db) {
|
|
93
|
+
// get() is a thin wrapper around getWithSource that discards the
|
|
94
|
+
// source tag. Keeps the hot-path a single implementation.
|
|
95
|
+
const result = await this.getWithSource(qualifiedKey, keyDef, tenantId, userId, db);
|
|
96
|
+
return result.value;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async getWithSource(
|
|
100
|
+
qualifiedKey,
|
|
101
|
+
keyDef,
|
|
102
|
+
tenantId,
|
|
103
|
+
userId,
|
|
104
|
+
db,
|
|
105
|
+
): Promise<ConfigValueWithSource> {
|
|
106
|
+
// Resolution cascade based on scope
|
|
107
|
+
// user: userId+tenantId → tenantId → default
|
|
108
|
+
// tenant: tenantId → SYSTEM_TENANT_ID → default
|
|
109
|
+
// system: SYSTEM_TENANT_ID → default
|
|
110
|
+
const lookups: Array<{
|
|
111
|
+
tenantId: string;
|
|
112
|
+
userId: string | null;
|
|
113
|
+
source: ConfigValueSource;
|
|
114
|
+
}> = [];
|
|
115
|
+
|
|
116
|
+
switch (keyDef.scope) {
|
|
117
|
+
case "user":
|
|
118
|
+
lookups.push({ tenantId, userId, source: "user-row" });
|
|
119
|
+
lookups.push({ tenantId, userId: null, source: "tenant-row" });
|
|
120
|
+
break;
|
|
121
|
+
case "tenant":
|
|
122
|
+
lookups.push({ tenantId, userId: null, source: "tenant-row" });
|
|
123
|
+
lookups.push({ tenantId: SYSTEM_TENANT_ID, userId: null, source: "system-row" });
|
|
124
|
+
break;
|
|
125
|
+
case "system":
|
|
126
|
+
lookups.push({ tenantId: SYSTEM_TENANT_ID, userId: null, source: "system-row" });
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
assertUnreachable(keyDef.scope, "config scope");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const lookup of lookups) {
|
|
133
|
+
const row = await findRow(qualifiedKey, lookup.tenantId, lookup.userId, db);
|
|
134
|
+
if (row?.value !== null && row?.value !== undefined) {
|
|
135
|
+
let raw = row.value;
|
|
136
|
+
if (keyDef.encrypted && encryption) {
|
|
137
|
+
raw = encryption.decrypt(raw);
|
|
138
|
+
}
|
|
139
|
+
return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// App-Boot-Override: one step above the feature-declared default.
|
|
144
|
+
// The override only kicks in when no scope-specific row exists —
|
|
145
|
+
// a tenant-admin that deliberately set a value still wins.
|
|
146
|
+
if (appOverrides?.has(qualifiedKey)) {
|
|
147
|
+
return { value: appOverrides.get(qualifiedKey), source: "app-override" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Computed fallback: plan-based values, feature-flag-Resolver etc.
|
|
151
|
+
// Called after rows + app-overrides miss, before the static default.
|
|
152
|
+
if (keyDef.computed) {
|
|
153
|
+
const value = await keyDef.computed({ tenantId, userId, db });
|
|
154
|
+
return { value, source: "computed" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (keyDef.default !== undefined) {
|
|
158
|
+
return { value: keyDef.default, source: "default" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { value: undefined, source: "missing" };
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async getAll(tenantId, userId, db) {
|
|
165
|
+
// Only load rows relevant to this user/tenant (system + tenant + user scope)
|
|
166
|
+
const rows = await db
|
|
167
|
+
.select()
|
|
168
|
+
.from(configValuesTable)
|
|
169
|
+
.where(
|
|
170
|
+
or(
|
|
171
|
+
// System-level values
|
|
172
|
+
and(eq(configValuesTable.tenantId, SYSTEM_TENANT_ID), isNull(configValuesTable.userId)),
|
|
173
|
+
// Tenant-level values
|
|
174
|
+
and(eq(configValuesTable.tenantId, tenantId), isNull(configValuesTable.userId)),
|
|
175
|
+
// User-level values
|
|
176
|
+
and(eq(configValuesTable.tenantId, tenantId), eq(configValuesTable.userId, userId)),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const result = new Map<string, ConfigRow>();
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
const r = row as ConfigRow;
|
|
183
|
+
// Higher specificity wins: user > tenant > system. Under the ES
|
|
184
|
+
// schema system rows carry SYSTEM_TENANT_ID instead of NULL, so the
|
|
185
|
+
// "tenant set" check compares against the sentinel rather than null.
|
|
186
|
+
const specificityOf = (candidate: ConfigRow) =>
|
|
187
|
+
(candidate.userId !== null ? 2 : 0) + (candidate.tenantId !== SYSTEM_TENANT_ID ? 1 : 0);
|
|
188
|
+
const existing = result.get(r.key);
|
|
189
|
+
if (!existing || specificityOf(r) > specificityOf(existing)) {
|
|
190
|
+
result.set(r.key, r);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return result;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Validates an app-override Map against a registry before the resolver
|
|
200
|
+
// ingests it. Call this from buildServer (or the app's boot wiring) with
|
|
201
|
+
// the registry's config keys + the overrides the app-dev provided.
|
|
202
|
+
//
|
|
203
|
+
// Four classes of errors, all thrown eagerly so a typo in boot-code fails
|
|
204
|
+
// immediately instead of silently returning stale defaults in production:
|
|
205
|
+
// 1. unknown key → feature probably renamed or not required
|
|
206
|
+
// 2. type mismatch → wrong primitive (number for a text key, etc.)
|
|
207
|
+
// 3. bounds / options violation → same rule as tenant-admin Set
|
|
208
|
+
// 4. computed conflict → app-override would silently beat plan-based
|
|
209
|
+
// logic; incompatible paradigm, requires explicit resolution
|
|
210
|
+
//
|
|
211
|
+
// The return is a narrowed Map ready to hand to createConfigResolver.
|
|
212
|
+
export function validateAppOverrides(
|
|
213
|
+
registry: {
|
|
214
|
+
getConfigKey: (
|
|
215
|
+
key: string,
|
|
216
|
+
) => import("@cosmicdrift/kumiko-framework/engine").ConfigKeyDefinition | undefined;
|
|
217
|
+
},
|
|
218
|
+
overrides: Readonly<Record<string, string | number | boolean>>,
|
|
219
|
+
): AppConfigOverrides {
|
|
220
|
+
const validated = new Map<string, string | number | boolean>();
|
|
221
|
+
|
|
222
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
223
|
+
const keyDef = registry.getConfigKey(key);
|
|
224
|
+
if (!keyDef) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`App-Boot-Override for unknown config key "${key}" — no feature declares it. Typo or missing feature-require?`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// computed keys encode plan-based business logic ("zahlender Tenant
|
|
231
|
+
// bekommt 100 MB"). An app-override would silently beat that — the
|
|
232
|
+
// cascade puts overrides above computed, so the plan becomes invisible.
|
|
233
|
+
// Force an explicit decision: either drop the override and trust the
|
|
234
|
+
// computed function, or drop computed if the deploy really wants a
|
|
235
|
+
// static default for everyone. Mixing silently is a footgun.
|
|
236
|
+
if (keyDef.computed) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`App-Boot-Override for "${key}": this key has a computed resolver (plan-based / derived). App-overrides would silently bypass that logic — remove the override, or remove the computed resolver if a flat deploy-default is intended.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Per keyDef.type narrow value inline — TS narrowt nicht durch
|
|
243
|
+
// `typeof value !== typeForKey(...)` (typeForKey returnt string,
|
|
244
|
+
// kein discriminator). Das vermeidet `value as string|number` casts
|
|
245
|
+
// unten, weil value innerhalb des Branches schon typed ist.
|
|
246
|
+
if (keyDef.type === "number") {
|
|
247
|
+
if (typeof value !== "number") {
|
|
248
|
+
throw new Error(`App-Boot-Override for "${key}": expected number, got ${typeof value}`);
|
|
249
|
+
}
|
|
250
|
+
if (keyDef.bounds) {
|
|
251
|
+
const { min, max } = keyDef.bounds;
|
|
252
|
+
if (min !== undefined && value < min) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`App-Boot-Override for "${key}": value ${value} is below bounds.min (${min})`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (max !== undefined && value > max) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`App-Boot-Override for "${key}": value ${value} is above bounds.max (${max})`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} else if (keyDef.type === "boolean") {
|
|
264
|
+
if (typeof value !== "boolean") {
|
|
265
|
+
throw new Error(`App-Boot-Override for "${key}": expected boolean, got ${typeof value}`);
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// text or select
|
|
269
|
+
if (typeof value !== "string") {
|
|
270
|
+
throw new Error(`App-Boot-Override for "${key}": expected string, got ${typeof value}`);
|
|
271
|
+
}
|
|
272
|
+
if (keyDef.type === "select" && keyDef.options && !keyDef.options.includes(value)) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`App-Boot-Override for "${key}": value "${value}" is not in options [${keyDef.options.join(", ")}]`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
validated.set(key, value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return validated;
|
|
283
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
|
|
4
|
+
// Config values are event-sourced. Each (key, scope) is its own aggregate
|
|
5
|
+
// stream — lifecycle events `configValue.created / .updated / .deleted`
|
|
6
|
+
// flow through createEventStoreExecutor, which writes the stream + this
|
|
7
|
+
// projection in one TX. Reads stay O(1) against the projection.
|
|
8
|
+
//
|
|
9
|
+
// System-scope rows use SYSTEM_TENANT_ID (not null) — buildBaseColumns
|
|
10
|
+
// (via buildDrizzleTable) forces tenant_id NOT NULL, so die pre-ES "NULL
|
|
11
|
+
// means system" convention is replaced with a fixed sentinel. Der unique
|
|
12
|
+
// index über (key, tenant_id, user_id) prevent duplicate writes at the DB
|
|
13
|
+
// level — deklariert via entity.indexes.
|
|
14
|
+
//
|
|
15
|
+
// Single-Source-of-Truth: `configValueEntity`. Die DB-Tabelle wird über
|
|
16
|
+
// buildDrizzleTable aus der EntityDefinition abgeleitet, der unique-Index
|
|
17
|
+
// ist via entity.indexes deklariert. Plural-Re-Export `configValuesTable`
|
|
18
|
+
// dient handlers (`reset.write.ts` etc.) als typisierte Drizzle-Table-Ref.
|
|
19
|
+
export const configValueEntity = createEntity({
|
|
20
|
+
table: "read_config_values",
|
|
21
|
+
fields: {
|
|
22
|
+
key: createTextField({ required: true }),
|
|
23
|
+
// value is JSON-encoded primitive (or encrypted blob). Nullable so a
|
|
24
|
+
// deleted-then-recreated stream can signal "reset to default" without
|
|
25
|
+
// breaking the null-vs-missing distinction the resolver already draws.
|
|
26
|
+
value: createTextField({}),
|
|
27
|
+
// user-scope row: userId populated. tenant- / system-scope: null.
|
|
28
|
+
userId: createTextField({}),
|
|
29
|
+
},
|
|
30
|
+
indexes: [
|
|
31
|
+
{ unique: true, columns: ["key", "tenantId", "userId"], name: "read_config_values_unique" },
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const configValuesTable = buildDrizzleTable("config-value", configValueEntity);
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Shared helpers for the config feature's write + query handlers.
|
|
2
|
+
// Extracted from set.write.ts so reset.write.ts + values.query.ts don't
|
|
3
|
+
// have to cross-import from another handler file.
|
|
4
|
+
|
|
5
|
+
import { type DbConnection, fetchOne, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import {
|
|
7
|
+
type ConfigKeyDefinition,
|
|
8
|
+
type ConfigScope,
|
|
9
|
+
ConfigScopes,
|
|
10
|
+
type Registry,
|
|
11
|
+
type SessionUser,
|
|
12
|
+
SYSTEM_ROLE,
|
|
13
|
+
SYSTEM_TENANT_ID,
|
|
14
|
+
type TenantId,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import {
|
|
17
|
+
AccessDeniedError,
|
|
18
|
+
type KumikoError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
UnprocessableError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
type WriteFailure,
|
|
23
|
+
writeFailure,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
25
|
+
import { assertUnreachable } from "@cosmicdrift/kumiko-framework/utils";
|
|
26
|
+
import { eq, isNull } from "drizzle-orm";
|
|
27
|
+
import { ConfigErrors } from "./constants";
|
|
28
|
+
import { configValuesTable } from "./table";
|
|
29
|
+
|
|
30
|
+
export type ConfigRowLookup = {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly version: number;
|
|
33
|
+
readonly value: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Locate an existing config_values row by the (key, tenant, user) triple —
|
|
37
|
+
// the effective natural key. System-scope rows carry SYSTEM_TENANT_ID on
|
|
38
|
+
// the tenant_id column (the post-ES projection is NOT NULL), so callers
|
|
39
|
+
// hand in that sentinel directly instead of null. userId stays nullable
|
|
40
|
+
// because tenant-scope rows have no user.
|
|
41
|
+
export async function findConfigRow(
|
|
42
|
+
db: DbConnection | TenantDb,
|
|
43
|
+
key: string,
|
|
44
|
+
tenantId: TenantId,
|
|
45
|
+
userId: string | null,
|
|
46
|
+
): Promise<ConfigRowLookup | null> {
|
|
47
|
+
const userCond =
|
|
48
|
+
userId !== null ? eq(configValuesTable.userId, userId) : isNull(configValuesTable.userId);
|
|
49
|
+
const row = await fetchOne<ConfigRowLookup>(
|
|
50
|
+
db,
|
|
51
|
+
configValuesTable,
|
|
52
|
+
eq(configValuesTable.key, key),
|
|
53
|
+
eq(configValuesTable.tenantId, tenantId),
|
|
54
|
+
userCond,
|
|
55
|
+
);
|
|
56
|
+
if (!row) return null;
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
version: row.version,
|
|
60
|
+
value: row.value ?? null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Three-stage pre-write gate that set + reset both need: resolve the key
|
|
65
|
+
// definition (404 when unknown), check the user's roles can write it (403),
|
|
66
|
+
// and map the target scope to the (tenantId | null, userId | null) pair the
|
|
67
|
+
// resolver expects. Handler-specific follow-ups (scope-compat check, value
|
|
68
|
+
// type check) stay inline in the handler that needs them.
|
|
69
|
+
export type PrepareConfigWriteArgs = {
|
|
70
|
+
readonly registry: Registry;
|
|
71
|
+
readonly user: SessionUser;
|
|
72
|
+
readonly key: string;
|
|
73
|
+
// When omitted (or explicit `undefined`), falls back to `keyDef.scope`.
|
|
74
|
+
readonly scope?: ConfigScope | undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type PrepareConfigWriteResult =
|
|
78
|
+
| { readonly ok: false; readonly failure: WriteFailure }
|
|
79
|
+
| {
|
|
80
|
+
readonly ok: true;
|
|
81
|
+
readonly keyDef: ConfigKeyDefinition;
|
|
82
|
+
readonly scope: ConfigScope;
|
|
83
|
+
// Non-null even for system-scope (SYSTEM_TENANT_ID sentinel) — the
|
|
84
|
+
// projection column is NOT NULL and callers should never have to
|
|
85
|
+
// bridge null → sentinel themselves.
|
|
86
|
+
readonly tenantId: TenantId;
|
|
87
|
+
readonly userId: string | null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function prepareConfigWrite(args: PrepareConfigWriteArgs): PrepareConfigWriteResult {
|
|
91
|
+
const { registry, user, key, scope: requestedScope } = args;
|
|
92
|
+
|
|
93
|
+
const keyDef = registry.getConfigKey(key);
|
|
94
|
+
if (!keyDef) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
failure: writeFailure(
|
|
98
|
+
new NotFoundError("configKey", key, { i18nKey: "config.errors.unknownKey" }),
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const writeError = checkWriteAccess(keyDef, user.roles);
|
|
104
|
+
if (writeError) return { ok: false, failure: writeFailure(writeError) };
|
|
105
|
+
|
|
106
|
+
const scope = requestedScope ?? keyDef.scope;
|
|
107
|
+
const { tenantId, userId } = resolveScopeIds(scope, user.tenantId, user.id);
|
|
108
|
+
return { ok: true, keyDef, scope, tenantId, userId };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function hasConfigAccess(
|
|
112
|
+
accessList: readonly string[],
|
|
113
|
+
userRoles: readonly string[],
|
|
114
|
+
): boolean {
|
|
115
|
+
if (accessList.includes("all")) return true;
|
|
116
|
+
return userRoles.some((role) => accessList.includes(role));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function checkWriteAccess(
|
|
120
|
+
keyDef: ConfigKeyDefinition,
|
|
121
|
+
userRoles: readonly string[],
|
|
122
|
+
): KumikoError | null {
|
|
123
|
+
if (keyDef.access.write.includes(SYSTEM_ROLE)) {
|
|
124
|
+
// Pre-ES the system-only block was absolute — out-of-band writes went
|
|
125
|
+
// through resolver.set, bypassing the whole access layer. Post-ES
|
|
126
|
+
// every write flows through this handler + executor, so the escape
|
|
127
|
+
// hatch becomes explicit: SYSTEM_ROLE (jobs / seeds / framework-
|
|
128
|
+
// internal work) may write; everyone else is rejected.
|
|
129
|
+
if (userRoles.includes(SYSTEM_ROLE)) return null;
|
|
130
|
+
return new AccessDeniedError({
|
|
131
|
+
message: "config key is system-only",
|
|
132
|
+
i18nKey: "config.errors.systemOnly",
|
|
133
|
+
details: { reason: ConfigErrors.systemOnly },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (!hasConfigAccess(keyDef.access.write, userRoles)) {
|
|
137
|
+
return new AccessDeniedError({
|
|
138
|
+
message: "config write access denied",
|
|
139
|
+
details: { requiredRoles: keyDef.access.write },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function validateScope(
|
|
146
|
+
requestedScope: ConfigScope,
|
|
147
|
+
definedScope: ConfigScope,
|
|
148
|
+
key: string,
|
|
149
|
+
): KumikoError | null {
|
|
150
|
+
const levels: Record<ConfigScope, number> = {
|
|
151
|
+
[ConfigScopes.system]: 0,
|
|
152
|
+
[ConfigScopes.tenant]: 1,
|
|
153
|
+
[ConfigScopes.user]: 2,
|
|
154
|
+
};
|
|
155
|
+
if (levels[requestedScope] > levels[definedScope]) {
|
|
156
|
+
return new UnprocessableError("invalid_scope", {
|
|
157
|
+
i18nKey: "config.errors.invalidScope",
|
|
158
|
+
details: { key, definedScope, requestedScope },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resolveScopeIds(
|
|
165
|
+
scope: ConfigScope,
|
|
166
|
+
tenantId: TenantId,
|
|
167
|
+
userId: string,
|
|
168
|
+
): { tenantId: TenantId; userId: string | null } {
|
|
169
|
+
switch (scope) {
|
|
170
|
+
case ConfigScopes.system:
|
|
171
|
+
return { tenantId: SYSTEM_TENANT_ID, userId: null };
|
|
172
|
+
case ConfigScopes.tenant:
|
|
173
|
+
return { tenantId, userId: null };
|
|
174
|
+
case ConfigScopes.user:
|
|
175
|
+
return { tenantId, userId };
|
|
176
|
+
default:
|
|
177
|
+
assertUnreachable(scope, "config scope");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function validateType(
|
|
182
|
+
value: string | number | boolean,
|
|
183
|
+
keyDef: ConfigKeyDefinition,
|
|
184
|
+
): KumikoError | null {
|
|
185
|
+
switch (keyDef.type) {
|
|
186
|
+
case "number":
|
|
187
|
+
if (typeof value !== "number") return typeMismatch("number", typeof value);
|
|
188
|
+
break;
|
|
189
|
+
case "boolean":
|
|
190
|
+
if (typeof value !== "boolean") return typeMismatch("boolean", typeof value);
|
|
191
|
+
break;
|
|
192
|
+
case "text":
|
|
193
|
+
if (typeof value !== "string") return typeMismatch("string", typeof value);
|
|
194
|
+
break;
|
|
195
|
+
case "select":
|
|
196
|
+
if (typeof value !== "string") return typeMismatch("string", typeof value);
|
|
197
|
+
if (keyDef.options && !keyDef.options.includes(value)) {
|
|
198
|
+
return new ValidationError({
|
|
199
|
+
fields: [
|
|
200
|
+
{
|
|
201
|
+
path: "value",
|
|
202
|
+
code: "invalid_option",
|
|
203
|
+
i18nKey: "errors.validation.invalid_option",
|
|
204
|
+
params: { value, options: keyDef.options },
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
default:
|
|
211
|
+
assertUnreachable(keyDef.type, "config key type");
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function typeMismatch(expected: string, actual: string): KumikoError {
|
|
217
|
+
return new ValidationError({
|
|
218
|
+
fields: [
|
|
219
|
+
{
|
|
220
|
+
path: "value",
|
|
221
|
+
code: "invalid_type",
|
|
222
|
+
i18nKey: "errors.validation.invalid_type",
|
|
223
|
+
params: { expected, received: actual },
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Bounds enforcement for numeric config keys. Returns null when OK or when
|
|
230
|
+
// bounds don't apply (non-number key, no bounds declared, or upstream
|
|
231
|
+
// type-validation would already reject non-numeric values).
|
|
232
|
+
export function validateBounds(
|
|
233
|
+
value: string | number | boolean,
|
|
234
|
+
keyDef: ConfigKeyDefinition,
|
|
235
|
+
): KumikoError | null {
|
|
236
|
+
if (keyDef.type !== "number" || !keyDef.bounds) return null;
|
|
237
|
+
// skip: validateType runs first and catches non-numeric values
|
|
238
|
+
if (typeof value !== "number") return null;
|
|
239
|
+
|
|
240
|
+
const { min, max } = keyDef.bounds;
|
|
241
|
+
|
|
242
|
+
if (min !== undefined && value < min) {
|
|
243
|
+
return new ValidationError({
|
|
244
|
+
fields: [
|
|
245
|
+
{
|
|
246
|
+
path: "value",
|
|
247
|
+
code: "out_of_bounds",
|
|
248
|
+
i18nKey: "errors.validation.out_of_bounds",
|
|
249
|
+
params: { value, min, max: max ?? null },
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (max !== undefined && value > max) {
|
|
255
|
+
return new ValidationError({
|
|
256
|
+
fields: [
|
|
257
|
+
{
|
|
258
|
+
path: "value",
|
|
259
|
+
code: "out_of_bounds",
|
|
260
|
+
i18nKey: "errors.validation.out_of_bounds",
|
|
261
|
+
params: { value, min: min ?? null, max },
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null;
|
|
268
|
+
}
|