@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,193 @@
|
|
|
1
|
+
// Rotation job. Scans tenant_secrets for rows whose kekVersion is older
|
|
2
|
+
// than provider.currentVersion() and rewraps their DEK under the new KEK
|
|
3
|
+
// — the ciphertext itself never changes, only the 60-byte DEK wrapper
|
|
4
|
+
// and the kek_version column. See architecture/core-secrets.md for the
|
|
5
|
+
// full rotation story.
|
|
6
|
+
//
|
|
7
|
+
// The job is idempotent: re-running it after a partial failure picks up
|
|
8
|
+
// the remaining old-version rows. Consumers that want a time-bound run
|
|
9
|
+
// pass a maxDurationMs in the payload.
|
|
10
|
+
//
|
|
11
|
+
// Post-ES pivot: each rotation is an executor.update against the
|
|
12
|
+
// tenantSecret aggregate. The resulting `.updated` event carries
|
|
13
|
+
// {changes, previous} with BOTH envelopes — useful for a full rotation
|
|
14
|
+
// audit trail (when did row X flip from v1 to v2, who triggered it).
|
|
15
|
+
// Concurrency-guard shifts from the pre-ES `WHERE kek_version = old`
|
|
16
|
+
// check to the executor's stream-version check; a parallel secrets.set
|
|
17
|
+
// that landed the row on the new kekVersion first surfaces here as a
|
|
18
|
+
// version_conflict error (counted as "skipped", not "failed").
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createEventStoreExecutor,
|
|
22
|
+
createTenantDb,
|
|
23
|
+
type DbConnection,
|
|
24
|
+
type TenantDb,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
28
|
+
import { rewrapDek } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
+
import { ne } from "drizzle-orm";
|
|
30
|
+
import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table";
|
|
31
|
+
|
|
32
|
+
const DEFAULT_BATCH_SIZE = 100;
|
|
33
|
+
const DEFAULT_MAX_FAILURES = 10;
|
|
34
|
+
const SYSTEM_ROLES = ["system"] as const;
|
|
35
|
+
|
|
36
|
+
const executor = createEventStoreExecutor(tenantSecretsTable, tenantSecretEntity, {
|
|
37
|
+
entityName: "tenant-secret",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type RotateJobPayload = {
|
|
41
|
+
readonly batchSize?: number;
|
|
42
|
+
readonly maxDurationMs?: number;
|
|
43
|
+
readonly maxFailures?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type RotateJobResult = {
|
|
47
|
+
readonly migrated: number;
|
|
48
|
+
readonly failed: number;
|
|
49
|
+
readonly batchesProcessed: number;
|
|
50
|
+
readonly stoppedReason: "empty" | "timeout" | "signal" | "too_many_failures";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
54
|
+
const payload = rawPayload as RotateJobPayload;
|
|
55
|
+
if (!ctx.masterKeyProvider) {
|
|
56
|
+
throw new InternalError({
|
|
57
|
+
message:
|
|
58
|
+
"[secrets:rotate] ctx.masterKeyProvider missing — wire it via extraContext.masterKeyProvider at boot.",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const provider = ctx.masterKeyProvider;
|
|
62
|
+
if (!ctx.db) {
|
|
63
|
+
throw new InternalError({
|
|
64
|
+
message: "[secrets:rotate] ctx.db missing — job context requires a database connection.",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const db = ctx.db as DbConnection;
|
|
68
|
+
const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
69
|
+
const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
70
|
+
const deadline = payload.maxDurationMs
|
|
71
|
+
? Date.now() + payload.maxDurationMs
|
|
72
|
+
: Number.POSITIVE_INFINITY;
|
|
73
|
+
|
|
74
|
+
let migrated = 0;
|
|
75
|
+
let failed = 0;
|
|
76
|
+
let batchesProcessed = 0;
|
|
77
|
+
let stoppedReason: RotateJobResult["stoppedReason"] = "empty";
|
|
78
|
+
|
|
79
|
+
// Reuse a TenantDb-per-tenant map so we don't rebuild the wrapper for
|
|
80
|
+
// each row in the same tenant. Rotation typically hits one tenant in a
|
|
81
|
+
// batch; the map trims an allocation without adding complexity.
|
|
82
|
+
const tdbCache = new Map<TenantId, TenantDb>();
|
|
83
|
+
function tdbFor(tenantId: TenantId): TenantDb {
|
|
84
|
+
let existing = tdbCache.get(tenantId);
|
|
85
|
+
if (!existing) {
|
|
86
|
+
existing = createTenantDb(db, tenantId, "system");
|
|
87
|
+
tdbCache.set(tenantId, existing);
|
|
88
|
+
}
|
|
89
|
+
return existing;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
while (true) {
|
|
93
|
+
if (ctx.signal?.aborted) {
|
|
94
|
+
stoppedReason = "signal";
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (Date.now() >= deadline) {
|
|
98
|
+
stoppedReason = "timeout";
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const targetVersion = provider.currentVersion();
|
|
103
|
+
const batch = await db
|
|
104
|
+
.select({
|
|
105
|
+
id: tenantSecretsTable.id,
|
|
106
|
+
tenantId: tenantSecretsTable.tenantId,
|
|
107
|
+
version: tenantSecretsTable.version,
|
|
108
|
+
envelope: tenantSecretsTable.envelope,
|
|
109
|
+
kekVersion: tenantSecretsTable.kekVersion,
|
|
110
|
+
})
|
|
111
|
+
.from(tenantSecretsTable)
|
|
112
|
+
.where(ne(tenantSecretsTable.kekVersion, targetVersion))
|
|
113
|
+
.limit(batchSize);
|
|
114
|
+
|
|
115
|
+
if (batch.length === 0) break;
|
|
116
|
+
|
|
117
|
+
batchesProcessed++;
|
|
118
|
+
|
|
119
|
+
if (failed >= maxFailures) {
|
|
120
|
+
stoppedReason = "too_many_failures";
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const row of batch) {
|
|
125
|
+
if (failed >= maxFailures) {
|
|
126
|
+
stoppedReason = "too_many_failures";
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const oldEnvelope = {
|
|
131
|
+
ciphertext: Buffer.from(row.envelope.ciphertext, "base64"),
|
|
132
|
+
iv: Buffer.from(row.envelope.iv, "base64"),
|
|
133
|
+
authTag: Buffer.from(row.envelope.authTag, "base64"),
|
|
134
|
+
encryptedDek: Buffer.from(row.envelope.encryptedDek, "base64"),
|
|
135
|
+
kekVersion: row.envelope.kekVersion,
|
|
136
|
+
};
|
|
137
|
+
const rotated = await rewrapDek(oldEnvelope, provider);
|
|
138
|
+
|
|
139
|
+
if (rotated.kekVersion === row.kekVersion) continue;
|
|
140
|
+
|
|
141
|
+
const newEnvelope: StoredEnvelope = {
|
|
142
|
+
ciphertext: rotated.ciphertext.toString("base64"),
|
|
143
|
+
iv: rotated.iv.toString("base64"),
|
|
144
|
+
authTag: rotated.authTag.toString("base64"),
|
|
145
|
+
encryptedDek: rotated.encryptedDek.toString("base64"),
|
|
146
|
+
kekVersion: rotated.kekVersion,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const actor: SessionUser = {
|
|
150
|
+
id: "system",
|
|
151
|
+
tenantId: row.tenantId as TenantId,
|
|
152
|
+
roles: SYSTEM_ROLES,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const result = await executor.update(
|
|
156
|
+
{
|
|
157
|
+
id: row.id,
|
|
158
|
+
version: row.version,
|
|
159
|
+
changes: {
|
|
160
|
+
envelope: newEnvelope,
|
|
161
|
+
kekVersion: rotated.kekVersion,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
actor,
|
|
165
|
+
tdbFor(row.tenantId as TenantId),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// version_conflict == another writer (secrets.set or a parallel
|
|
169
|
+
// rotation worker) beat us. Count as "skipped" and move on — the
|
|
170
|
+
// row is already in a valid state, potentially even past target.
|
|
171
|
+
if (!result.isSuccess) {
|
|
172
|
+
if (result.error.code === "version_conflict") continue;
|
|
173
|
+
failed++;
|
|
174
|
+
ctx.log?.warn?.(`[secrets:rotate] executor rejected row ${row.id}`, {
|
|
175
|
+
code: result.error.code,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
failed++;
|
|
181
|
+
ctx.log?.warn?.(`[secrets:rotate] failed to rotate row ${row.id}`, { err });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
migrated++;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (stoppedReason === "too_many_failures") break;
|
|
188
|
+
if (batch.length < batchSize) break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result: RotateJobResult = { migrated, failed, batchesProcessed, stoppedReason };
|
|
192
|
+
ctx.log?.info?.(`[secrets:rotate] complete: ${JSON.stringify(result)}`);
|
|
193
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireSecretsContext } from "../feature";
|
|
4
|
+
|
|
5
|
+
export const setWrite = defineWriteHandler({
|
|
6
|
+
name: "set",
|
|
7
|
+
schema: z.object({
|
|
8
|
+
key: z.string().min(1).max(100),
|
|
9
|
+
value: z.string(),
|
|
10
|
+
// Optional fixed-length preview — if the caller's UI wants a domain-
|
|
11
|
+
// specific redaction ("sk_live_abc…xyz") it can send it here; else the
|
|
12
|
+
// handler derives a generic one (first-3-chars + bullets).
|
|
13
|
+
redactedPreview: z.string().max(50).optional(),
|
|
14
|
+
hint: z.string().max(200).optional(),
|
|
15
|
+
}),
|
|
16
|
+
access: { roles: ["TenantAdmin"] },
|
|
17
|
+
handler: async (event, ctx) => {
|
|
18
|
+
const secrets = requireSecretsContext(ctx, "secrets:write:set");
|
|
19
|
+
const { key, value, redactedPreview, hint } = event.payload;
|
|
20
|
+
|
|
21
|
+
// Preview-priority: explicit payload param > feature-declared redact
|
|
22
|
+
// (via r.secret()) > generic default. A feature that declared a
|
|
23
|
+
// domain-aware redact (Stripe keys: "sk_test...2345") wins over the
|
|
24
|
+
// framework default unless the caller sent a specific preview.
|
|
25
|
+
const keyDef = ctx.registry.getSecretKey(key);
|
|
26
|
+
const featureRedact = keyDef?.redact;
|
|
27
|
+
const redactFn: (v: string) => string = redactedPreview
|
|
28
|
+
? () => redactedPreview
|
|
29
|
+
: (featureRedact ?? defaultRedact);
|
|
30
|
+
|
|
31
|
+
await secrets.set(event.user.tenantId, key, value, {
|
|
32
|
+
redact: redactFn,
|
|
33
|
+
...(hint ? { hint } : {}),
|
|
34
|
+
updatedBy: event.user.id,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
isSuccess: true,
|
|
39
|
+
data: { key, redactedPreview: redactedPreview ?? redactFn(value) },
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Fallback redaction: show at most the first 3 chars + trailing bullets.
|
|
45
|
+
// Deliberately conservative — a too-generous preview defeats the point.
|
|
46
|
+
function defaultRedact(value: string): string {
|
|
47
|
+
if (value.length === 0) return "";
|
|
48
|
+
const prefix = value.slice(0, Math.min(3, value.length));
|
|
49
|
+
return `${prefix}${"•".repeat(Math.max(1, value.length - 3))}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createSecretsContext,
|
|
3
|
+
createSecretsFeature,
|
|
4
|
+
requireSecretsContext,
|
|
5
|
+
type SecretsContext,
|
|
6
|
+
type SecretsContextOptions,
|
|
7
|
+
type StoredEnvelope,
|
|
8
|
+
type StoredMetadata,
|
|
9
|
+
TENANT_SECRET_READ_EVENT,
|
|
10
|
+
tenantSecretsTable,
|
|
11
|
+
} from "./feature";
|
|
12
|
+
export {
|
|
13
|
+
type RotateJobPayload,
|
|
14
|
+
type RotateJobResult,
|
|
15
|
+
rotateJob,
|
|
16
|
+
} from "./handlers/rotate.job";
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Feature-level accessor injected as `ctx.secrets` at boot. Not a HTTP API —
|
|
2
|
+
// feature code that needs a plaintext secret (SMTP-connect, Stripe-call, …)
|
|
3
|
+
// pulls it via ctx.secrets.get. Cleartext never crosses the wire.
|
|
4
|
+
//
|
|
5
|
+
// Post-ES pivot: all three ops (get/set/delete) flow through the events-
|
|
6
|
+
// table.
|
|
7
|
+
// - set → executor.create / .update on the tenantSecret aggregate
|
|
8
|
+
// - delete → executor.delete
|
|
9
|
+
// - get → low-level append of tenantSecretRead-event on a fresh
|
|
10
|
+
// aggregate-stream (one-event-per-read, so parallel reads never
|
|
11
|
+
// race on the secret's own version). The audit invariant ("every
|
|
12
|
+
// read logged") now sits on the events-table instead of a
|
|
13
|
+
// dedicated audit-table.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createEventStoreExecutor,
|
|
17
|
+
createTenantDb,
|
|
18
|
+
type DbConnection,
|
|
19
|
+
fetchOne,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { InternalError, type WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
23
|
+
import { append, type EventMetadata } from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
|
+
import {
|
|
25
|
+
createDekCache,
|
|
26
|
+
createSecret,
|
|
27
|
+
type DekCache,
|
|
28
|
+
decryptValue,
|
|
29
|
+
encryptValue,
|
|
30
|
+
type MasterKeyProvider,
|
|
31
|
+
type SecretsContext,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
33
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
34
|
+
import { and, eq } from "drizzle-orm";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import {
|
|
37
|
+
type StoredEnvelope,
|
|
38
|
+
type StoredMetadata,
|
|
39
|
+
tenantSecretEntity,
|
|
40
|
+
tenantSecretsTable,
|
|
41
|
+
} from "./table";
|
|
42
|
+
|
|
43
|
+
// Re-export the framework interface so consumers of bundled-features/secrets
|
|
44
|
+
// don't need to reach into @cosmicdrift/kumiko-framework/secrets separately.
|
|
45
|
+
export type { Secret, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
46
|
+
|
|
47
|
+
export type SecretsContextOptions = {
|
|
48
|
+
readonly db: DbConnection;
|
|
49
|
+
readonly masterKeyProvider: MasterKeyProvider;
|
|
50
|
+
// Shared DEK cache. Default: a fresh 5-min TTL cache. Pass in a shared
|
|
51
|
+
// instance if several features decrypt overlapping secret sets — lowers
|
|
52
|
+
// provider-call count across the app.
|
|
53
|
+
readonly dekCache?: DekCache;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Synthetic actor identity for set/delete — the executor wants a full
|
|
57
|
+
// SessionUser, but the secrets-context API takes only a user-id string
|
|
58
|
+
// (via opts.updatedBy / opts.deletedBy). `system`-role mirrors how jobs
|
|
59
|
+
// and seeds attribute out-of-band writes: non-admin paths stay blocked,
|
|
60
|
+
// framework-internal ops keep working.
|
|
61
|
+
const SYSTEM_ROLES = ["system"] as const;
|
|
62
|
+
|
|
63
|
+
// Secret-read audit-event type name + schema. Colocated here instead of
|
|
64
|
+
// in secrets-feature.ts because the feature file imports the context
|
|
65
|
+
// (via createSecretsContext), so schema-in-feature-file would cycle.
|
|
66
|
+
// secrets-feature.ts re-exports `secretReadSchema` for r.defineEvent.
|
|
67
|
+
export const TENANT_SECRET_READ_EVENT = "secrets:event:read";
|
|
68
|
+
export const secretReadSchema = z.object({
|
|
69
|
+
key: z.string(),
|
|
70
|
+
userId: z.string(),
|
|
71
|
+
handlerName: z.string(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const executor = createEventStoreExecutor(tenantSecretsTable, tenantSecretEntity, {
|
|
75
|
+
entityName: "tenant-secret",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function resolveKey(keyOrHandle: string | { readonly name: string }): string {
|
|
79
|
+
return typeof keyOrHandle === "string" ? keyOrHandle : keyOrHandle.name;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Wrap a provider so its unwrapDek goes through the cache. Lets decryptValue
|
|
83
|
+
// use the full provider contract without knowing about caching — separation
|
|
84
|
+
// of concerns: decryptValue handles crypto, cache handles cost.
|
|
85
|
+
function cachedProvider(provider: MasterKeyProvider, cache: DekCache): MasterKeyProvider {
|
|
86
|
+
return {
|
|
87
|
+
wrapDek: provider.wrapDek.bind(provider),
|
|
88
|
+
unwrapDek: (encryptedDek, version) => cache.unwrapDek(encryptedDek, version, provider),
|
|
89
|
+
currentVersion: provider.currentVersion.bind(provider),
|
|
90
|
+
isAvailable: provider.isAvailable.bind(provider),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decodeEnvelope(stored: StoredEnvelope): {
|
|
95
|
+
ciphertext: Buffer;
|
|
96
|
+
iv: Buffer;
|
|
97
|
+
authTag: Buffer;
|
|
98
|
+
encryptedDek: Buffer;
|
|
99
|
+
kekVersion: number;
|
|
100
|
+
} {
|
|
101
|
+
return {
|
|
102
|
+
ciphertext: Buffer.from(stored.ciphertext, "base64"),
|
|
103
|
+
iv: Buffer.from(stored.iv, "base64"),
|
|
104
|
+
authTag: Buffer.from(stored.authTag, "base64"),
|
|
105
|
+
encryptedDek: Buffer.from(stored.encryptedDek, "base64"),
|
|
106
|
+
kekVersion: stored.kekVersion,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createSecretsContext(opts: SecretsContextOptions): SecretsContext {
|
|
111
|
+
const { db, masterKeyProvider } = opts;
|
|
112
|
+
const provider = cachedProvider(masterKeyProvider, opts.dekCache ?? createDekCache());
|
|
113
|
+
|
|
114
|
+
type SecretLookupRow = {
|
|
115
|
+
readonly id: string;
|
|
116
|
+
readonly version: number;
|
|
117
|
+
readonly envelope: StoredEnvelope;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
async function lookup(tenantId: string, key: string): Promise<SecretLookupRow | undefined> {
|
|
121
|
+
return fetchOne<SecretLookupRow>(
|
|
122
|
+
db,
|
|
123
|
+
tenantSecretsTable,
|
|
124
|
+
eq(tenantSecretsTable.tenantId, tenantId),
|
|
125
|
+
eq(tenantSecretsTable.key, key),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
async get(tenantId, keyOrHandle, auditCtx) {
|
|
131
|
+
const key = resolveKey(keyOrHandle);
|
|
132
|
+
// Atomic audit + read: a decrypt that "escaped" the audit trail
|
|
133
|
+
// (because the audit-append threw) would violate the compliance
|
|
134
|
+
// promise "every read is logged". Wrapping both in a TX means
|
|
135
|
+
// either the caller gets the plaintext AND a read-event row, or
|
|
136
|
+
// neither. Reads without audit (framework-internal, rotation job)
|
|
137
|
+
// skip the TX — there's nothing to couple.
|
|
138
|
+
if (!auditCtx) {
|
|
139
|
+
const existing = await lookup(tenantId, key);
|
|
140
|
+
if (!existing) return undefined;
|
|
141
|
+
const plaintext = await decryptValue(decodeEnvelope(existing.envelope), provider);
|
|
142
|
+
return createSecret(plaintext);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const plaintext = await db.transaction(async (tx) => {
|
|
146
|
+
// Inline select inside the TX — fetchOne's SelectChainDb shape
|
|
147
|
+
// doesn't widen to drizzle's tx-object cleanly. Structurally
|
|
148
|
+
// identical; the one-off repeat beats a double-cast at the
|
|
149
|
+
// call site.
|
|
150
|
+
const [row] = await tx
|
|
151
|
+
.select({ envelope: tenantSecretsTable.envelope })
|
|
152
|
+
.from(tenantSecretsTable)
|
|
153
|
+
.where(and(eq(tenantSecretsTable.tenantId, tenantId), eq(tenantSecretsTable.key, key)))
|
|
154
|
+
.limit(1);
|
|
155
|
+
if (!row) return undefined;
|
|
156
|
+
const envelope = row.envelope;
|
|
157
|
+
const pt = await decryptValue(decodeEnvelope(envelope), provider);
|
|
158
|
+
|
|
159
|
+
// One event per read on its own aggregate-stream (fresh UUID as
|
|
160
|
+
// aggregateId). Avoids version-conflicts between parallel reads —
|
|
161
|
+
// a shared stream on the tenantSecret-aggregate would force
|
|
162
|
+
// serialization and turn read-amplification into lock-amplification.
|
|
163
|
+
// MSP consumers still group by payload.key if they want per-secret
|
|
164
|
+
// read counts.
|
|
165
|
+
const readId = generateId();
|
|
166
|
+
const metadata: EventMetadata = { userId: auditCtx.userId };
|
|
167
|
+
// Parse against the registered schema so the low-level append
|
|
168
|
+
// here gets the same validation guarantee as ctx.appendEvent.
|
|
169
|
+
// A payload-shape drift between schema + call-site fails at the
|
|
170
|
+
// source instead of landing on the events-stream.
|
|
171
|
+
const payload = secretReadSchema.parse({
|
|
172
|
+
key,
|
|
173
|
+
userId: auditCtx.userId,
|
|
174
|
+
handlerName: auditCtx.handlerName,
|
|
175
|
+
});
|
|
176
|
+
await append(tx, {
|
|
177
|
+
aggregateId: readId,
|
|
178
|
+
aggregateType: "tenantSecretRead",
|
|
179
|
+
tenantId,
|
|
180
|
+
expectedVersion: 0,
|
|
181
|
+
type: TENANT_SECRET_READ_EVENT,
|
|
182
|
+
payload,
|
|
183
|
+
metadata,
|
|
184
|
+
});
|
|
185
|
+
return pt;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (plaintext === undefined) return undefined;
|
|
189
|
+
// Brand the plaintext only after audit committed. The response
|
|
190
|
+
// serializer rejects any Secret<> it finds on the response path.
|
|
191
|
+
return createSecret(plaintext);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async set(tenantId, keyOrHandle, value, setOpts = {}) {
|
|
195
|
+
const key = resolveKey(keyOrHandle);
|
|
196
|
+
const envelope = await encryptValue(value, masterKeyProvider);
|
|
197
|
+
const stored: StoredEnvelope = {
|
|
198
|
+
ciphertext: envelope.ciphertext.toString("base64"),
|
|
199
|
+
iv: envelope.iv.toString("base64"),
|
|
200
|
+
authTag: envelope.authTag.toString("base64"),
|
|
201
|
+
encryptedDek: envelope.encryptedDek.toString("base64"),
|
|
202
|
+
kekVersion: envelope.kekVersion,
|
|
203
|
+
};
|
|
204
|
+
const metadata: StoredMetadata = {
|
|
205
|
+
...(setOpts.redact ? { redactedPreview: setOpts.redact(value) } : {}),
|
|
206
|
+
...(setOpts.hint ? { hint: setOpts.hint } : {}),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const actor: SessionUser = {
|
|
210
|
+
id: setOpts.updatedBy ?? "system",
|
|
211
|
+
tenantId,
|
|
212
|
+
roles: SYSTEM_ROLES,
|
|
213
|
+
};
|
|
214
|
+
const tdb = createTenantDb(db, tenantId, "system");
|
|
215
|
+
|
|
216
|
+
const existing = await lookup(tenantId, key);
|
|
217
|
+
const commonFields = {
|
|
218
|
+
envelope: stored,
|
|
219
|
+
kekVersion: envelope.kekVersion,
|
|
220
|
+
metadata,
|
|
221
|
+
lastRotatedAt: Temporal.Now.instant(),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (existing) {
|
|
225
|
+
const result = await executor.update(
|
|
226
|
+
{
|
|
227
|
+
id: existing.id,
|
|
228
|
+
version: existing.version,
|
|
229
|
+
changes: commonFields,
|
|
230
|
+
},
|
|
231
|
+
actor,
|
|
232
|
+
tdb,
|
|
233
|
+
);
|
|
234
|
+
if (!result.isSuccess) throw wrapSetFailure(result.error);
|
|
235
|
+
// skip: update path done — don't fall through into the create branch below.
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const result = await executor.create(
|
|
241
|
+
{
|
|
242
|
+
key,
|
|
243
|
+
tenantId,
|
|
244
|
+
...commonFields,
|
|
245
|
+
},
|
|
246
|
+
actor,
|
|
247
|
+
tdb,
|
|
248
|
+
);
|
|
249
|
+
if (!result.isSuccess) throw wrapSetFailure(result.error);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Race-fallback: a concurrent set won the insert. Re-lookup and
|
|
252
|
+
// convert to an update. The unique-index on (tenant, key) is what
|
|
253
|
+
// triggers this path.
|
|
254
|
+
const afterRace = await lookup(tenantId, key);
|
|
255
|
+
if (!afterRace) throw err;
|
|
256
|
+
const result = await executor.update(
|
|
257
|
+
{
|
|
258
|
+
id: afterRace.id,
|
|
259
|
+
version: afterRace.version,
|
|
260
|
+
changes: commonFields,
|
|
261
|
+
},
|
|
262
|
+
actor,
|
|
263
|
+
tdb,
|
|
264
|
+
);
|
|
265
|
+
if (!result.isSuccess) throw wrapSetFailure(result.error);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
async delete(tenantId, keyOrHandle, deleteOpts = {}) {
|
|
270
|
+
const key = resolveKey(keyOrHandle);
|
|
271
|
+
const existing = await lookup(tenantId, key);
|
|
272
|
+
if (!existing) return false;
|
|
273
|
+
|
|
274
|
+
const actor: SessionUser = {
|
|
275
|
+
id: deleteOpts.deletedBy ?? "system",
|
|
276
|
+
tenantId,
|
|
277
|
+
roles: SYSTEM_ROLES,
|
|
278
|
+
};
|
|
279
|
+
const tdb = createTenantDb(db, tenantId, "system");
|
|
280
|
+
const result = await executor.delete({ id: existing.id }, actor, tdb);
|
|
281
|
+
return result.isSuccess;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Wrap an executor-level write failure into a KumikoError so callers of
|
|
287
|
+
// ctx.secrets.set / .delete can still branch on .code / details / i18nKey
|
|
288
|
+
// after it propagates up. Plain `new Error(...)` would have stripped the
|
|
289
|
+
// structured payload the error-contract promises.
|
|
290
|
+
function wrapSetFailure(err: WriteErrorInfo): InternalError {
|
|
291
|
+
return new InternalError({
|
|
292
|
+
message: `[secrets.set] executor returned failure: ${err.code}`,
|
|
293
|
+
i18nKey: "secrets.errors.set_failed",
|
|
294
|
+
details: { executorCode: err.code, executorDetails: err.details ?? {} },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildBaseColumns,
|
|
3
|
+
instant,
|
|
4
|
+
integer,
|
|
5
|
+
jsonb,
|
|
6
|
+
table,
|
|
7
|
+
text,
|
|
8
|
+
uniqueIndex,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import {
|
|
11
|
+
createEntity,
|
|
12
|
+
createNumberField,
|
|
13
|
+
createTextField,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { sql } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
// Envelope stored as a single jsonb blob. All ops are upsert-by-(tenantId, key)
|
|
18
|
+
// so there's no value in decomposing the envelope into separate columns —
|
|
19
|
+
// we never query or index on any sub-field of the envelope itself.
|
|
20
|
+
//
|
|
21
|
+
// kekVersion IS broken out as its own column so the rotation job can filter
|
|
22
|
+
// `WHERE kek_version != currentVersion()` with an index on just that column
|
|
23
|
+
// without deserializing the jsonb. Duplicated inside envelope too — the two
|
|
24
|
+
// always stay in sync via the write path.
|
|
25
|
+
export type StoredEnvelope = {
|
|
26
|
+
readonly ciphertext: string; // base64
|
|
27
|
+
readonly iv: string; // base64
|
|
28
|
+
readonly authTag: string; // base64
|
|
29
|
+
readonly encryptedDek: string; // base64
|
|
30
|
+
readonly kekVersion: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type StoredMetadata = {
|
|
34
|
+
readonly redactedPreview?: string;
|
|
35
|
+
readonly hint?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Entity registration for the ES pivot. Only `key` + `kekVersion` are
|
|
39
|
+
// declared as business-validated fields; the jsonb columns (envelope,
|
|
40
|
+
// metadata) and the instant column (lastRotatedAt) ride along as extra
|
|
41
|
+
// table-columns. The executor writes whatever keys land in `flatData`
|
|
42
|
+
// into both the projection row AND the event payload, so the whole
|
|
43
|
+
// envelope round-trips through events.
|
|
44
|
+
//
|
|
45
|
+
// The envelope is cipher-safe by construction (AES-GCM ciphertext + authTag
|
|
46
|
+
// + DEK encrypted under the KEK). A leaked event row can't recover the
|
|
47
|
+
// plaintext without the master key — so shipping it into the events-table
|
|
48
|
+
// doesn't weaken the threat model vs. the pre-ES tenant_secrets column.
|
|
49
|
+
export const tenantSecretEntity = createEntity({
|
|
50
|
+
table: "read_tenant_secrets",
|
|
51
|
+
fields: {
|
|
52
|
+
key: createTextField({ required: true }),
|
|
53
|
+
kekVersion: createNumberField({ required: true }),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const tenantSecretsTable = table(
|
|
58
|
+
"read_tenant_secrets",
|
|
59
|
+
{
|
|
60
|
+
...buildBaseColumns(false, "uuid"),
|
|
61
|
+
key: text("key").notNull(),
|
|
62
|
+
envelope: jsonb("envelope").$type<StoredEnvelope>().notNull(),
|
|
63
|
+
kekVersion: integer("kek_version").notNull(),
|
|
64
|
+
metadata: jsonb("metadata").$type<StoredMetadata>().default({}).notNull(),
|
|
65
|
+
lastRotatedAt: instant("last_rotated_at").default(sql`now()`).notNull(),
|
|
66
|
+
},
|
|
67
|
+
(t) => [uniqueIndex("read_tenant_secrets_tenant_key_unique").on(t.tenantId, t.key)],
|
|
68
|
+
);
|