@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,23 @@
|
|
|
1
|
+
// Feature name
|
|
2
|
+
export const CONFIG_FEATURE = "config" as const;
|
|
3
|
+
|
|
4
|
+
// Qualified write handler names (QN format: scope:type:name)
|
|
5
|
+
export const ConfigHandlers = {
|
|
6
|
+
set: "config:write:set",
|
|
7
|
+
reset: "config:write:reset",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
// Qualified query handler names (QN format: scope:type:name)
|
|
11
|
+
export const ConfigQueries = {
|
|
12
|
+
values: "config:query:values",
|
|
13
|
+
schema: "config:query:schema",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
// Error codes
|
|
17
|
+
export const ConfigErrors = {
|
|
18
|
+
unknownKey: "unknown_config_key",
|
|
19
|
+
systemOnly: "config_key_is_system_only",
|
|
20
|
+
invalidScope: "invalid_scope",
|
|
21
|
+
typeError: "type_error",
|
|
22
|
+
invalidOption: "invalid_option",
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { DbConnection, EncryptionProvider, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
type ConfigAccessor,
|
|
4
|
+
type ConfigAccessorFactory,
|
|
5
|
+
type ConfigKeyHandle,
|
|
6
|
+
type ConfigKeyType,
|
|
7
|
+
type ConfigValue,
|
|
8
|
+
defineFeature,
|
|
9
|
+
type FeatureDefinition,
|
|
10
|
+
type HandlerContext,
|
|
11
|
+
type Registry,
|
|
12
|
+
type TenantId,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
15
|
+
import { resetWrite } from "./handlers/reset.write";
|
|
16
|
+
import { schemaQuery } from "./handlers/schema.query";
|
|
17
|
+
import { setWrite } from "./handlers/set.write";
|
|
18
|
+
import { valuesQuery } from "./handlers/values.query";
|
|
19
|
+
import type { ConfigResolver } from "./resolver";
|
|
20
|
+
import { configValueEntity } from "./table";
|
|
21
|
+
|
|
22
|
+
export type ConfigContext = { readonly config: ConfigAccessor };
|
|
23
|
+
|
|
24
|
+
export function createConfigFeature(): FeatureDefinition {
|
|
25
|
+
return defineFeature("config", (r) => {
|
|
26
|
+
r.systemScope();
|
|
27
|
+
|
|
28
|
+
// One aggregate stream per (key, scope) pair — the executor handles the
|
|
29
|
+
// lifecycle events `configValue.created / .updated / .deleted` plus the
|
|
30
|
+
// projection write in one TX. Subscribers that need config-change
|
|
31
|
+
// semantics listen to those auto-events via r.multiStreamProjection
|
|
32
|
+
// (see docs/plans/architecture/event-sourcing-pivot.md §4.7).
|
|
33
|
+
r.entity("config-value", configValueEntity);
|
|
34
|
+
|
|
35
|
+
const handlers = {
|
|
36
|
+
set: r.writeHandler(setWrite),
|
|
37
|
+
reset: r.writeHandler(resetWrite),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const queries = {
|
|
41
|
+
values: r.queryHandler(valuesQuery),
|
|
42
|
+
schema: r.queryHandler(schemaQuery),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return { handlers, queries };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createConfigAccessor(
|
|
50
|
+
registry: Registry,
|
|
51
|
+
resolver: ConfigResolver,
|
|
52
|
+
tenantId: TenantId,
|
|
53
|
+
userId: string,
|
|
54
|
+
db: DbConnection | TenantDb,
|
|
55
|
+
): ConfigAccessor {
|
|
56
|
+
async function configAccessor(
|
|
57
|
+
qualifiedKey: string,
|
|
58
|
+
): Promise<string | number | boolean | undefined>;
|
|
59
|
+
async function configAccessor<T extends ConfigKeyType>(
|
|
60
|
+
handle: ConfigKeyHandle<T>,
|
|
61
|
+
): Promise<ConfigValue<T> | undefined>;
|
|
62
|
+
async function configAccessor(
|
|
63
|
+
keyOrHandle: string | ConfigKeyHandle<ConfigKeyType>,
|
|
64
|
+
): Promise<string | number | boolean | undefined> {
|
|
65
|
+
const qualifiedKey = typeof keyOrHandle === "string" ? keyOrHandle : keyOrHandle.name;
|
|
66
|
+
const keyDef = registry.getConfigKey(qualifiedKey);
|
|
67
|
+
if (!keyDef) return undefined;
|
|
68
|
+
return resolver.get(qualifiedKey, keyDef, tenantId, userId, db);
|
|
69
|
+
}
|
|
70
|
+
return configAccessor;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Pass to the test-stack / server-boot as `_configAccessorFactory` —
|
|
74
|
+
// `buildHandlerContext` mints a per-user `ctx.config` from this.
|
|
75
|
+
export function createConfigAccessorFactory(
|
|
76
|
+
registry: Registry,
|
|
77
|
+
resolver: ConfigResolver,
|
|
78
|
+
): ConfigAccessorFactory {
|
|
79
|
+
return ({ user, db }) => createConfigAccessor(registry, resolver, user.tenantId, user.id, db);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Single point of truth for "this handler needs the resolver". Throws a
|
|
83
|
+
// proper InternalError (with i18n) instead of bare Error, and points the
|
|
84
|
+
// caller at the boot wiring step that's missing — so a future debug
|
|
85
|
+
// session reads "config feature not wired into AppContext" instead of a
|
|
86
|
+
// generic "configResolver missing".
|
|
87
|
+
export function requireConfigResolver(ctx: HandlerContext, handlerName: string): ConfigResolver {
|
|
88
|
+
if (!ctx.configResolver) {
|
|
89
|
+
throw new InternalError({
|
|
90
|
+
message:
|
|
91
|
+
`[${handlerName}] ctx.configResolver missing — pass createConfigAccessorFactory's ` +
|
|
92
|
+
`output via extraContext._configAccessorFactory and the resolver via ` +
|
|
93
|
+
`extraContext.configResolver at boot.`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return ctx.configResolver;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Mirror of requireConfigResolver for the encryption round-trip side.
|
|
100
|
+
// Only keys declared `encrypted: true` need this — the setter calls it
|
|
101
|
+
// lazily so apps that never wire encryption still boot (and only crash
|
|
102
|
+
// if a handler tries to write an encrypted key without the provider in
|
|
103
|
+
// place, pointing at the exact wiring gap).
|
|
104
|
+
export function requireConfigEncryption(
|
|
105
|
+
ctx: HandlerContext,
|
|
106
|
+
handlerName: string,
|
|
107
|
+
): EncryptionProvider {
|
|
108
|
+
if (!ctx.configEncryption) {
|
|
109
|
+
throw new InternalError({
|
|
110
|
+
message:
|
|
111
|
+
`[${handlerName}] ctx.configEncryption missing — at least one config key declares ` +
|
|
112
|
+
`encrypted: true, so the boot wiring must pass an EncryptionProvider via ` +
|
|
113
|
+
`extraContext.configEncryption (same instance the resolver was built with).`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return ctx.configEncryption;
|
|
117
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
type ConfigKeyDefinition,
|
|
4
|
+
ConfigScopes,
|
|
5
|
+
createSystemConfig,
|
|
6
|
+
createTenantConfig,
|
|
7
|
+
createUserConfig,
|
|
8
|
+
type Registry,
|
|
9
|
+
type SessionUser,
|
|
10
|
+
SYSTEM_ROLE,
|
|
11
|
+
SYSTEM_TENANT_ID,
|
|
12
|
+
type TenantId,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { describe, expect, test } from "vitest";
|
|
15
|
+
import { prepareConfigWrite, validateBounds } from "../../write-helpers";
|
|
16
|
+
|
|
17
|
+
// Minimal Registry stub — only getConfigKey is exercised by prepareConfigWrite.
|
|
18
|
+
function registryStub(keys: Record<string, unknown>): Registry {
|
|
19
|
+
return {
|
|
20
|
+
getConfigKey: (name: string) => keys[name] as never,
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: the other Registry methods aren't touched by prepareConfigWrite — cast documents the intent.
|
|
22
|
+
} as any;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Minimal user shape — prepareConfigWrite reads roles / tenantId / id.
|
|
26
|
+
function userStub(
|
|
27
|
+
roles: readonly string[],
|
|
28
|
+
tenantId = "tenant-1" as TenantId,
|
|
29
|
+
id = "user-1",
|
|
30
|
+
): SessionUser {
|
|
31
|
+
return { id, tenantId, roles } as SessionUser;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Built via the public factory (same path a feature-dev takes in r.config).
|
|
35
|
+
// Kept const so a test that asserts `result.keyDef === TENANT_KEY_DEF`
|
|
36
|
+
// actually compares to the stable reference.
|
|
37
|
+
const TENANT_KEY_DEF = createTenantConfig("text", { write: access.roles("Admin") });
|
|
38
|
+
|
|
39
|
+
describe("prepareConfigWrite", () => {
|
|
40
|
+
test("returns NotFound failure when the key is not registered", () => {
|
|
41
|
+
const result = prepareConfigWrite({
|
|
42
|
+
registry: registryStub({}),
|
|
43
|
+
user: userStub(["Admin"]),
|
|
44
|
+
key: "unknown:key",
|
|
45
|
+
});
|
|
46
|
+
expect(result.ok).toBe(false);
|
|
47
|
+
if (result.ok) throw new Error("unreachable");
|
|
48
|
+
expect(result.failure.isSuccess).toBe(false);
|
|
49
|
+
expect(result.failure.error.code).toBe("not_found");
|
|
50
|
+
expect(result.failure.error.i18nKey).toBe("config.errors.unknownKey");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns AccessDenied when the user's roles do not include the key's write roles", () => {
|
|
54
|
+
const result = prepareConfigWrite({
|
|
55
|
+
registry: registryStub({ "ns:config:foo": TENANT_KEY_DEF }),
|
|
56
|
+
user: userStub(["ReadOnly"]),
|
|
57
|
+
key: "ns:config:foo",
|
|
58
|
+
});
|
|
59
|
+
expect(result.ok).toBe(false);
|
|
60
|
+
if (result.ok) throw new Error("unreachable");
|
|
61
|
+
expect(result.failure.error.code).toBe("access_denied");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("returns AccessDenied (system-only) when the key's write access is SYSTEM_ROLE", () => {
|
|
65
|
+
const systemKey = createTenantConfig("text", { write: access.system });
|
|
66
|
+
const result = prepareConfigWrite({
|
|
67
|
+
registry: registryStub({ "ns:config:secret": systemKey }),
|
|
68
|
+
user: userStub(["SystemAdmin"]),
|
|
69
|
+
key: "ns:config:secret",
|
|
70
|
+
});
|
|
71
|
+
expect(result.ok).toBe(false);
|
|
72
|
+
if (result.ok) throw new Error("unreachable");
|
|
73
|
+
expect(result.failure.error.i18nKey).toBe("config.errors.systemOnly");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("system-only key is writable by a caller that actually carries SYSTEM_ROLE", () => {
|
|
77
|
+
// Post-ES escape hatch: out-of-band writes (jobs, seeds, framework-
|
|
78
|
+
// internal flows) used to bypass checkWriteAccess via resolver.set.
|
|
79
|
+
// The handler is the only write path now, so SYSTEM_ROLE must flow
|
|
80
|
+
// through — otherwise billing/quota/session-cleanup jobs can't touch
|
|
81
|
+
// system-only config anymore. Non-system roles stay blocked by the
|
|
82
|
+
// test above.
|
|
83
|
+
const systemKey = createTenantConfig("text", { write: access.system });
|
|
84
|
+
const result = prepareConfigWrite({
|
|
85
|
+
registry: registryStub({ "ns:config:secret": systemKey }),
|
|
86
|
+
user: userStub([SYSTEM_ROLE]),
|
|
87
|
+
key: "ns:config:secret",
|
|
88
|
+
});
|
|
89
|
+
expect(result.ok).toBe(true);
|
|
90
|
+
if (!result.ok) throw new Error("unreachable");
|
|
91
|
+
expect(result.keyDef).toBe(systemKey);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("system-only key stays blocked for Admin even when they also carry other roles", () => {
|
|
95
|
+
// Regression guard: don't let "Admin + Billing" accidentally pass the
|
|
96
|
+
// system-only gate because role aggregation loosens the check.
|
|
97
|
+
const systemKey = createTenantConfig("text", { write: access.system });
|
|
98
|
+
const result = prepareConfigWrite({
|
|
99
|
+
registry: registryStub({ "ns:config:secret": systemKey }),
|
|
100
|
+
user: userStub(["Admin", "Billing"]),
|
|
101
|
+
key: "ns:config:secret",
|
|
102
|
+
});
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
if (result.ok) throw new Error("unreachable");
|
|
105
|
+
expect(result.failure.error.i18nKey).toBe("config.errors.systemOnly");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("ok-path falls back to the key's declared scope when no scope is passed", () => {
|
|
109
|
+
const result = prepareConfigWrite({
|
|
110
|
+
registry: registryStub({ "ns:config:foo": TENANT_KEY_DEF }),
|
|
111
|
+
user: userStub(["Admin"]),
|
|
112
|
+
key: "ns:config:foo",
|
|
113
|
+
});
|
|
114
|
+
expect(result.ok).toBe(true);
|
|
115
|
+
if (!result.ok) throw new Error("unreachable");
|
|
116
|
+
expect(result.scope).toBe(ConfigScopes.tenant);
|
|
117
|
+
expect(result.tenantId).toBe("tenant-1");
|
|
118
|
+
expect(result.userId).toBeNull();
|
|
119
|
+
expect(result.keyDef).toBe(TENANT_KEY_DEF);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("ok-path: scope=system maps tenantId to SYSTEM_TENANT_ID, userId to null", () => {
|
|
123
|
+
// Default system-scope write role is "system" (programmatic-only) —
|
|
124
|
+
// override to admin so a SystemAdmin can actually trigger this path.
|
|
125
|
+
// System-scope rows carry the SYSTEM_TENANT_ID sentinel on tenant_id
|
|
126
|
+
// (the projection column is NOT NULL post-ES).
|
|
127
|
+
const systemKey = createSystemConfig("text", { write: access.admin });
|
|
128
|
+
const result = prepareConfigWrite({
|
|
129
|
+
registry: registryStub({ "ns:config:foo": systemKey }),
|
|
130
|
+
user: userStub(["SystemAdmin"]),
|
|
131
|
+
key: "ns:config:foo",
|
|
132
|
+
scope: ConfigScopes.system,
|
|
133
|
+
});
|
|
134
|
+
expect(result.ok).toBe(true);
|
|
135
|
+
if (!result.ok) throw new Error("unreachable");
|
|
136
|
+
expect(result.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
137
|
+
expect(result.userId).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("ok-path: scope=user maps both tenantId and userId from the caller", () => {
|
|
141
|
+
const userKey = createUserConfig("text");
|
|
142
|
+
const result = prepareConfigWrite({
|
|
143
|
+
registry: registryStub({ "ns:config:foo": userKey }),
|
|
144
|
+
user: userStub(["Admin"], "t-99" as TenantId, "u-99"),
|
|
145
|
+
key: "ns:config:foo",
|
|
146
|
+
scope: ConfigScopes.user,
|
|
147
|
+
});
|
|
148
|
+
expect(result.ok).toBe(true);
|
|
149
|
+
if (!result.ok) throw new Error("unreachable");
|
|
150
|
+
expect(result.tenantId).toBe("t-99");
|
|
151
|
+
expect(result.userId).toBe("u-99");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("validateBounds", () => {
|
|
156
|
+
const numberKey = createTenantConfig("number", {
|
|
157
|
+
default: 10,
|
|
158
|
+
bounds: { min: 1, max: 100 },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("returns null for value inside bounds", () => {
|
|
162
|
+
expect(validateBounds(50, numberKey)).toBeNull();
|
|
163
|
+
expect(validateBounds(1, numberKey)).toBeNull(); // boundary min
|
|
164
|
+
expect(validateBounds(100, numberKey)).toBeNull(); // boundary max
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("returns out_of_bounds error when value is below min", () => {
|
|
168
|
+
const err = validateBounds(0, numberKey);
|
|
169
|
+
expect(err).not.toBeNull();
|
|
170
|
+
expect(err?.code).toBe("validation_error");
|
|
171
|
+
const details = err?.details as { fields: Array<{ code: string; params: unknown }> };
|
|
172
|
+
expect(details.fields[0]?.code).toBe("out_of_bounds");
|
|
173
|
+
expect(details.fields[0]?.params).toMatchObject({ value: 0, min: 1, max: 100 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("returns out_of_bounds error when value is above max", () => {
|
|
177
|
+
const err = validateBounds(101, numberKey);
|
|
178
|
+
expect(err).not.toBeNull();
|
|
179
|
+
const details = err?.details as { fields: Array<{ params: unknown }> };
|
|
180
|
+
expect(details.fields[0]?.params).toMatchObject({ value: 101, min: 1, max: 100 });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("returns null when bounds declared with only min (no max)", () => {
|
|
184
|
+
// Spread on a factory-produced def is the idiomatic way to tweak a
|
|
185
|
+
// single field without re-stating the whole declaration.
|
|
186
|
+
const minOnly: ConfigKeyDefinition = { ...numberKey, bounds: { min: 1 } };
|
|
187
|
+
expect(validateBounds(9999, minOnly)).toBeNull();
|
|
188
|
+
expect(validateBounds(0, minOnly)).not.toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("returns null when bounds declared with only max (no min)", () => {
|
|
192
|
+
const maxOnly: ConfigKeyDefinition = { ...numberKey, bounds: { max: 100 } };
|
|
193
|
+
expect(validateBounds(-9999, maxOnly)).toBeNull();
|
|
194
|
+
expect(validateBounds(101, maxOnly)).not.toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("returns null for keys without bounds declared (unrestricted)", () => {
|
|
198
|
+
const { bounds: _bounds, ...unrestricted } = numberKey;
|
|
199
|
+
expect(validateBounds(99999, unrestricted)).toBeNull();
|
|
200
|
+
expect(validateBounds(-99999, unrestricted)).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("returns null for non-number key types (bounds only applies to number)", () => {
|
|
204
|
+
const textKey = createTenantConfig("text");
|
|
205
|
+
// Even if bounds were somehow present on a text key, non-number values
|
|
206
|
+
// are unreachable here — validateType runs first and rejects them.
|
|
207
|
+
expect(validateBounds("any", textKey)).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { ConfigScopes, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { configValueEntity, configValuesTable } from "../table";
|
|
5
|
+
import { findConfigRow, prepareConfigWrite } from "../write-helpers";
|
|
6
|
+
|
|
7
|
+
const scopeEnum = z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]);
|
|
8
|
+
|
|
9
|
+
const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
10
|
+
entityName: "config-value",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const resetWrite = defineWriteHandler({
|
|
14
|
+
name: "reset",
|
|
15
|
+
schema: z.object({
|
|
16
|
+
key: z.string(),
|
|
17
|
+
scope: scopeEnum.optional(),
|
|
18
|
+
}),
|
|
19
|
+
// Per-key access enforcement lives inside the handler via checkWriteAccess.
|
|
20
|
+
access: { openToAll: true },
|
|
21
|
+
handler: async (event, ctx) => {
|
|
22
|
+
const db = ctx.db;
|
|
23
|
+
|
|
24
|
+
const prep = prepareConfigWrite({
|
|
25
|
+
registry: ctx.registry,
|
|
26
|
+
user: event.user,
|
|
27
|
+
key: event.payload.key,
|
|
28
|
+
scope: event.payload.scope,
|
|
29
|
+
});
|
|
30
|
+
if (!prep.ok) return prep.failure;
|
|
31
|
+
const { scope, tenantId, userId } = prep;
|
|
32
|
+
|
|
33
|
+
const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
|
|
34
|
+
|
|
35
|
+
// No-op when there is nothing to reset. Pre-ES this path silently did
|
|
36
|
+
// nothing too — keep the contract intact so callers can reset
|
|
37
|
+
// idempotently without a 404 dance.
|
|
38
|
+
if (existing) {
|
|
39
|
+
const result = await executor.delete({ id: existing.id }, event.user, db);
|
|
40
|
+
if (!result.isSuccess) return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { isSuccess: true, data: { key: event.payload.key, scope } };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ConfigKeyDefinition, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { hasConfigAccess } from "../write-helpers";
|
|
4
|
+
|
|
5
|
+
export const schemaQuery = defineQueryHandler({
|
|
6
|
+
name: "schema",
|
|
7
|
+
schema: z.object({}),
|
|
8
|
+
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
9
|
+
access: { openToAll: true },
|
|
10
|
+
handler: async (query, ctx) => {
|
|
11
|
+
const registry = ctx.registry;
|
|
12
|
+
const allKeys = registry.getAllConfigKeys();
|
|
13
|
+
const result: Record<string, ConfigKeyDefinition> = {};
|
|
14
|
+
|
|
15
|
+
for (const [qualifiedKey, keyDef] of allKeys) {
|
|
16
|
+
if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
|
|
17
|
+
result[qualifiedKey] = keyDef;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return result;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { ConfigScopes, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { requireConfigEncryption } from "../feature";
|
|
6
|
+
import { configValueEntity, configValuesTable } from "../table";
|
|
7
|
+
import {
|
|
8
|
+
findConfigRow,
|
|
9
|
+
prepareConfigWrite,
|
|
10
|
+
validateBounds,
|
|
11
|
+
validateScope,
|
|
12
|
+
validateType,
|
|
13
|
+
} from "../write-helpers";
|
|
14
|
+
|
|
15
|
+
const scopeEnum = z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]);
|
|
16
|
+
|
|
17
|
+
const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
18
|
+
entityName: "config-value",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const setWrite = defineWriteHandler({
|
|
22
|
+
name: "set",
|
|
23
|
+
schema: z.object({
|
|
24
|
+
key: z.string(),
|
|
25
|
+
value: z.union([z.string(), z.number(), z.boolean()]),
|
|
26
|
+
scope: scopeEnum.optional(),
|
|
27
|
+
}),
|
|
28
|
+
// Per-key access enforcement lives inside the handler via checkWriteAccess.
|
|
29
|
+
access: { openToAll: true },
|
|
30
|
+
handler: async (event, ctx) => {
|
|
31
|
+
const db = ctx.db;
|
|
32
|
+
|
|
33
|
+
const prep = prepareConfigWrite({
|
|
34
|
+
registry: ctx.registry,
|
|
35
|
+
user: event.user,
|
|
36
|
+
key: event.payload.key,
|
|
37
|
+
scope: event.payload.scope,
|
|
38
|
+
});
|
|
39
|
+
if (!prep.ok) return prep.failure;
|
|
40
|
+
const { keyDef, scope, tenantId, userId } = prep;
|
|
41
|
+
|
|
42
|
+
const scopeError = validateScope(scope, keyDef.scope, event.payload.key);
|
|
43
|
+
if (scopeError) return writeFailure(scopeError);
|
|
44
|
+
|
|
45
|
+
const typeError = validateType(event.payload.value, keyDef);
|
|
46
|
+
if (typeError) return writeFailure(typeError);
|
|
47
|
+
|
|
48
|
+
// Bounds enforcement: hard-reject (not silent-clamp). A caller that
|
|
49
|
+
// sends 9999 for a bounds.max=1000 key should see a 422 and fix their
|
|
50
|
+
// input — silent clamping would make `get` return a different value
|
|
51
|
+
// than what was sent, which is a UX trap with no upside.
|
|
52
|
+
const boundsError = validateBounds(event.payload.value, keyDef);
|
|
53
|
+
if (boundsError) return writeFailure(boundsError);
|
|
54
|
+
|
|
55
|
+
let serialized = JSON.stringify(event.payload.value);
|
|
56
|
+
if (keyDef.encrypted) {
|
|
57
|
+
const encryption = requireConfigEncryption(ctx, "config:write:set");
|
|
58
|
+
serialized = encryption.encrypt(serialized);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
|
|
62
|
+
|
|
63
|
+
if (existing) {
|
|
64
|
+
const result = await executor.update(
|
|
65
|
+
{
|
|
66
|
+
id: existing.id,
|
|
67
|
+
version: existing.version,
|
|
68
|
+
changes: { value: serialized },
|
|
69
|
+
},
|
|
70
|
+
event.user,
|
|
71
|
+
db,
|
|
72
|
+
);
|
|
73
|
+
if (!result.isSuccess) return result;
|
|
74
|
+
} else {
|
|
75
|
+
const result = await executor.create(
|
|
76
|
+
{
|
|
77
|
+
key: event.payload.key,
|
|
78
|
+
value: serialized,
|
|
79
|
+
tenantId,
|
|
80
|
+
userId,
|
|
81
|
+
},
|
|
82
|
+
event.user,
|
|
83
|
+
db,
|
|
84
|
+
);
|
|
85
|
+
if (!result.isSuccess) return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
isSuccess: true,
|
|
90
|
+
data: { key: event.payload.key, value: event.payload.value, scope },
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ConfigScope, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireConfigResolver } from "../feature";
|
|
4
|
+
import { deserializeValue } from "../resolver";
|
|
5
|
+
import { hasConfigAccess } from "../write-helpers";
|
|
6
|
+
|
|
7
|
+
export const valuesQuery = defineQueryHandler({
|
|
8
|
+
name: "values",
|
|
9
|
+
schema: z.object({}),
|
|
10
|
+
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
11
|
+
access: { openToAll: true },
|
|
12
|
+
handler: async (query, ctx) => {
|
|
13
|
+
const db = ctx.db;
|
|
14
|
+
const registry = ctx.registry;
|
|
15
|
+
const resolver = requireConfigResolver(ctx, "config:query:values");
|
|
16
|
+
|
|
17
|
+
const allKeys = registry.getAllConfigKeys();
|
|
18
|
+
const storedValues = await resolver.getAll(query.user.tenantId, query.user.id, db);
|
|
19
|
+
|
|
20
|
+
const result: Record<
|
|
21
|
+
string,
|
|
22
|
+
{ value: string | number | boolean | undefined; scope: ConfigScope }
|
|
23
|
+
> = {};
|
|
24
|
+
|
|
25
|
+
for (const [qualifiedKey, keyDef] of allKeys) {
|
|
26
|
+
if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
|
|
27
|
+
|
|
28
|
+
const stored = storedValues.get(qualifiedKey);
|
|
29
|
+
let value: string | number | boolean | undefined;
|
|
30
|
+
if (keyDef.encrypted) {
|
|
31
|
+
value = stored ? "••••••" : undefined;
|
|
32
|
+
} else if (stored?.value !== null && stored?.value !== undefined) {
|
|
33
|
+
value = deserializeValue(stored.value, keyDef.type);
|
|
34
|
+
} else {
|
|
35
|
+
value = keyDef.default;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
result[qualifiedKey] = { value, scope: keyDef.scope };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CONFIG_FEATURE,
|
|
3
|
+
ConfigErrors,
|
|
4
|
+
ConfigHandlers,
|
|
5
|
+
ConfigQueries,
|
|
6
|
+
} from "./constants";
|
|
7
|
+
export type { ConfigContext } from "./feature";
|
|
8
|
+
export {
|
|
9
|
+
createConfigAccessor,
|
|
10
|
+
createConfigAccessorFactory,
|
|
11
|
+
createConfigFeature,
|
|
12
|
+
} from "./feature";
|
|
13
|
+
export type { AppConfigOverrides, ConfigResolver } from "./resolver";
|
|
14
|
+
export { createConfigResolver, validateAppOverrides } from "./resolver";
|
|
15
|
+
export { configValuesTable } from "./table";
|