@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.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 +6 -8
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
- package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
- package/src/auth-email-password/constants.ts +0 -4
- package/src/auth-email-password/feature.ts +4 -8
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
- package/src/auth-email-password/handlers/login.write.ts +1 -4
- package/src/auth-email-password/password-hashing.ts +0 -11
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
- package/src/auth-email-password/web/auth-gate.tsx +4 -3
- package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
- package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
- package/src/auth-email-password/web/user-menu.tsx +1 -3
- package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
- package/src/config/__tests__/config.integration.test.ts +8 -7
- package/src/config/__tests__/deserialize-value.test.ts +6 -3
- package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
- package/src/config/feature.ts +8 -14
- package/src/config/handlers/set.write.ts +2 -2
- package/src/config/index.ts +3 -4
- package/src/config/resolver.ts +10 -34
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
- package/src/custom-fields/constants.ts +0 -1
- package/src/custom-fields/events.ts +0 -6
- package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
- package/src/custom-fields/wire-for-entity.ts +77 -116
- package/src/data-retention/__tests__/parse-override.test.ts +13 -13
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
- package/src/folders/__tests__/folders.integration.test.ts +0 -3
- package/src/folders/web/__tests__/tree.test.ts +0 -8
- package/src/folders/web/folder-manager.tsx +3 -1
- package/src/folders/web/i18n.ts +2 -0
- package/src/folders/web/tree.ts +2 -13
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
- package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
- package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
- package/src/ledger/recurring.ts +8 -17
- package/src/ledger/web/index.ts +2 -3
- package/src/legal-pages/feature.ts +3 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
- package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
- package/src/managed-pages/feature.ts +3 -1
- package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
- package/src/secrets/handlers/rotate.job.ts +98 -77
- package/src/secrets/secrets-context.ts +38 -7
- package/src/secrets/table.ts +7 -6
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/sessions/feature.ts +18 -51
- package/src/sessions/index.ts +2 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
- package/src/tags/__tests__/tags.integration.test.ts +1 -12
- package/src/tags/entity.ts +1 -6
- package/src/tags/schemas.ts +2 -9
- package/src/tags/web/tag-manager.tsx +1 -1
- package/src/template-resolver/testing.ts +1 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
- package/src/user/__tests__/user.integration.test.ts +0 -26
- package/src/user/handlers/find-for-auth.query.ts +5 -7
- package/src/user/index.ts +0 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
- package/src/user-data-rights/feature.ts +3 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
- package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
- package/src/user-data-rights/run-forget-cleanup.ts +0 -1
- package/src/user-data-rights/run-user-export.ts +4 -43
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
- package/src/user-data-rights/web/client-plugin.tsx +1 -6
- package/src/user-data-rights/web/i18n.ts +10 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
- package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
- package/src/user-profile/handlers/change-email.write.ts +8 -11
- package/src/user-profile/web/profile-screen.tsx +1 -12
- package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
- package/src/auth-email-password/password-hashing.test.ts +0 -35
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
- package/src/config/handlers/reencrypt.job.ts +0 -197
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
- package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
- package/src/personal-access-tokens/constants.ts +0 -33
- package/src/personal-access-tokens/feature.ts +0 -78
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
- package/src/personal-access-tokens/handlers/create.write.ts +0 -41
- package/src/personal-access-tokens/handlers/list.query.ts +0 -47
- package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
- package/src/personal-access-tokens/hash.ts +0 -17
- package/src/personal-access-tokens/index.ts +0 -45
- package/src/personal-access-tokens/resolver.ts +0 -58
- package/src/personal-access-tokens/roles.ts +0 -29
- package/src/personal-access-tokens/schema/api-token.ts +0 -56
- package/src/personal-access-tokens/scopes.ts +0 -43
- package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
- package/src/personal-access-tokens/web/i18n.ts +0 -69
- package/src/personal-access-tokens/web/index.ts +0 -7
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
- package/src/shared/chunked-entity-migration.ts +0 -89
- package/src/shared/index.ts +0 -7
- package/src/tags/__tests__/schemas.test.ts +0 -34
- package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
- package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
package/src/ledger/recurring.ts
CHANGED
|
@@ -53,22 +53,6 @@ export function scheduleReference(scheduleId: string, period: string): string {
|
|
|
53
53
|
return `schedule:${scheduleId}:${period}`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// A Storno mirror carries reference = the reversed tx's id, so any tx whose
|
|
57
|
-
// reference names another tx in the set marks that other tx reversed. Shared
|
|
58
|
-
// by mergeScheduleActuals (below) and confirm-schedule-period.write.ts's
|
|
59
|
-
// idempotency check (684/4) — same detection, kept in one place so a fix
|
|
60
|
-
// can't land in one call-site and drift from the other.
|
|
61
|
-
export function findReversedIds(
|
|
62
|
-
rows: readonly { readonly id: string; readonly reference: string | null }[],
|
|
63
|
-
): Set<string> {
|
|
64
|
-
const ids = new Set(rows.map((r) => r.id));
|
|
65
|
-
return new Set(
|
|
66
|
-
rows
|
|
67
|
-
.filter((r) => r.reference !== null && ids.has(r.reference))
|
|
68
|
-
.map((r) => r.reference as string),
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
56
|
// "YYYY-MM" ⇄ a month index (year*12 + monthOfYear), so window math is pure
|
|
73
57
|
// integer arithmetic — no Date object (no-date-api guard) and no DST/timezone
|
|
74
58
|
// drift. isoMonth tolerates a full ISO date ("2026-01-15" → "2026-01").
|
|
@@ -121,7 +105,14 @@ export function mergeScheduleActuals(
|
|
|
121
105
|
transactions: readonly LedgerTxRow[],
|
|
122
106
|
asOf: string,
|
|
123
107
|
): ScheduleMonth[] {
|
|
124
|
-
const
|
|
108
|
+
const txIds = new Set(transactions.map((t) => t.id));
|
|
109
|
+
// A Storno mirror carries reference = the reversed tx's id, so any tx whose
|
|
110
|
+
// reference names another tx marks that other tx reversed.
|
|
111
|
+
const reversedTxIds = new Set(
|
|
112
|
+
transactions
|
|
113
|
+
.filter((t) => t.reference != null && txIds.has(t.reference))
|
|
114
|
+
.map((t) => t.reference as string),
|
|
115
|
+
);
|
|
125
116
|
const asOfIndex = monthIndex(isoMonth(asOf));
|
|
126
117
|
|
|
127
118
|
return projection.map((projected) => {
|
package/src/ledger/web/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Client-safe ledger surface: QN constants
|
|
3
|
-
// (projection + Soll/Ist merge),
|
|
4
|
-
// pre-validation before dispatch) — nothing else. The full `../ledger` entry
|
|
2
|
+
// Client-safe ledger surface: QN constants + the pure recurring helpers
|
|
3
|
+
// (projection + Soll/Ist merge), nothing else. The full `../ledger` entry
|
|
5
4
|
// re-exports the feature/handlers/executor, which pull bun-db/postgres — a
|
|
6
5
|
// browser bundle that imports from there fails on Node builtins. A client screen
|
|
7
6
|
// (e.g. a rent-cashflow view) imports the dispatch QNs + the pure forecast/merge
|
|
@@ -26,7 +26,9 @@ type ByslugQueryBody = {
|
|
|
26
26
|
data: { title: string; body: string | null; updatedAt: string } | null;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
//
|
|
29
|
+
// Legal-Content ändert sich selten — ein 60s-Shared-Cache-Fenster spart den
|
|
30
|
+
// Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst die Content-Query),
|
|
31
|
+
// ohne dass Edits spürbar stale wirken.
|
|
30
32
|
const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
31
33
|
|
|
32
34
|
// legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
|
-
import type
|
|
13
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
14
14
|
import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
16
|
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
25
|
import {
|
|
26
26
|
createMutableMasterKeyProvider,
|
|
27
|
-
createTestEnvelopeCipher,
|
|
28
27
|
type MutableMasterKeyProvider,
|
|
29
28
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
30
29
|
import { z } from "zod";
|
|
@@ -88,8 +87,8 @@ let providerRef: MutableMasterKeyProvider;
|
|
|
88
87
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
89
88
|
|
|
90
89
|
beforeAll(async () => {
|
|
91
|
-
const encryption =
|
|
92
|
-
resolver = createConfigResolver({
|
|
90
|
+
const encryption = createEncryptionProvider(testEncryptionKey);
|
|
91
|
+
resolver = createConfigResolver({ encryption });
|
|
93
92
|
|
|
94
93
|
// Master-key for the secrets-feature. Production env shape:
|
|
95
94
|
// KUMIKO_SECRETS_MASTER_KEY_V1=<base64 32 bytes>
|
|
@@ -2,8 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { type BrandingTokens, EMPTY_BRANDING } from "../../page-render";
|
|
3
3
|
import { coerceBranding } from "../branding";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// coerceBranding is the IO boundary for the branding query's wire response:
|
|
6
|
+
// untrusted `unknown` → BrandingTokens with no `as` cast. Every missing or
|
|
7
|
+
// non-string field must collapse to "" so a malformed/empty response renders
|
|
8
|
+
// the unbranded default rather than throwing — and an attacker-controlled
|
|
9
|
+
// non-string (e.g. a logoUrl object) can never leak through as a live render
|
|
10
|
+
// token. Exercised only indirectly by the integration path before this.
|
|
7
11
|
|
|
8
12
|
describe("coerceBranding", () => {
|
|
9
13
|
const FULL: BrandingTokens = {
|
|
@@ -28,7 +28,9 @@ import { pageEntity } from "./table";
|
|
|
28
28
|
// Alias (publicstatus = "Admin") müssen TenantAdmin granten/mappen.
|
|
29
29
|
const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
|
|
30
30
|
|
|
31
|
-
//
|
|
31
|
+
// Published CMS-Content ändert sich selten — ein 60s-Shared-Cache-Fenster
|
|
32
|
+
// spart den Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst Page- +
|
|
33
|
+
// Branding-Query), Edits sind nach spätestens 60s live.
|
|
32
34
|
const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
33
35
|
|
|
34
36
|
// QN-Konstante als dokumentierter Public-Contract — der Render-Pfad ruft
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { randomBytes } from "node:crypto";
|
|
8
8
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
-
import type
|
|
9
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
10
|
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
12
|
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
unsafeCreateEntityTable,
|
|
19
19
|
unsafePushTables,
|
|
20
20
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
-
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
21
|
import { createConfigFeature } from "../../config";
|
|
23
22
|
import { createConfigAccessorFactory } from "../../config/feature";
|
|
24
23
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -134,8 +133,8 @@ let stack: TestStack;
|
|
|
134
133
|
let db: DbConnection;
|
|
135
134
|
|
|
136
135
|
beforeAll(async () => {
|
|
137
|
-
const encryption =
|
|
138
|
-
const resolver = createConfigResolver({
|
|
136
|
+
const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
137
|
+
const resolver = createConfigResolver({ encryption });
|
|
139
138
|
const masterKeyProvider = createEnvMasterKeyProvider({
|
|
140
139
|
env: {
|
|
141
140
|
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
@@ -26,12 +26,7 @@ import {
|
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
27
27
|
import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
28
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
29
|
-
import {
|
|
30
|
-
decodeStoredEnvelope,
|
|
31
|
-
encodeStoredEnvelope,
|
|
32
|
-
rewrapDek,
|
|
33
|
-
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
34
|
-
import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
|
|
29
|
+
import { rewrapDek } from "@cosmicdrift/kumiko-framework/secrets";
|
|
35
30
|
import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table";
|
|
36
31
|
|
|
37
32
|
const DEFAULT_BATCH_SIZE = 100;
|
|
@@ -52,7 +47,7 @@ export type RotateJobResult = {
|
|
|
52
47
|
readonly migrated: number;
|
|
53
48
|
readonly failed: number;
|
|
54
49
|
readonly batchesProcessed: number;
|
|
55
|
-
readonly stoppedReason:
|
|
50
|
+
readonly stoppedReason: "empty" | "timeout" | "signal" | "too_many_failures";
|
|
56
51
|
};
|
|
57
52
|
|
|
58
53
|
export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
@@ -76,6 +71,11 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
|
|
|
76
71
|
? Date.now() + payload.maxDurationMs
|
|
77
72
|
: Number.POSITIVE_INFINITY;
|
|
78
73
|
|
|
74
|
+
let migrated = 0;
|
|
75
|
+
let failed = 0;
|
|
76
|
+
let batchesProcessed = 0;
|
|
77
|
+
let stoppedReason: RotateJobResult["stoppedReason"] = "empty";
|
|
78
|
+
|
|
79
79
|
// Reuse a TenantDb-per-tenant map so we don't rebuild the wrapper for
|
|
80
80
|
// each row in the same tenant. Rotation typically hits one tenant in a
|
|
81
81
|
// batch; the map trims an allocation without adding complexity.
|
|
@@ -89,80 +89,101 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
|
|
|
89
89
|
return existing;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (batch.length
|
|
112
|
-
|
|
113
|
-
|
|
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 selectMany<{
|
|
104
|
+
id: string;
|
|
105
|
+
tenantId: string;
|
|
106
|
+
version: number;
|
|
107
|
+
envelope: StoredEnvelope;
|
|
108
|
+
kekVersion: number;
|
|
109
|
+
}>(db, tenantSecretsTable, { kekVersion: { ne: targetVersion } }, { limit: batchSize });
|
|
110
|
+
|
|
111
|
+
if (batch.length === 0) break;
|
|
112
|
+
|
|
113
|
+
batchesProcessed++;
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
115
|
+
if (failed >= maxFailures) {
|
|
116
|
+
stoppedReason = "too_many_failures";
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const row of batch) {
|
|
121
|
+
if (failed >= maxFailures) {
|
|
122
|
+
stoppedReason = "too_many_failures";
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const oldEnvelope = {
|
|
127
|
+
ciphertext: Buffer.from(row.envelope.ciphertext, "base64"),
|
|
128
|
+
iv: Buffer.from(row.envelope.iv, "base64"),
|
|
129
|
+
authTag: Buffer.from(row.envelope.authTag, "base64"),
|
|
130
|
+
encryptedDek: Buffer.from(row.envelope.encryptedDek, "base64"),
|
|
131
|
+
kekVersion: row.envelope.kekVersion,
|
|
132
|
+
};
|
|
133
|
+
const rotated = await rewrapDek(oldEnvelope, provider);
|
|
134
|
+
|
|
135
|
+
if (rotated.kekVersion === row.kekVersion) continue;
|
|
136
|
+
|
|
137
|
+
const newEnvelope: StoredEnvelope = {
|
|
138
|
+
ciphertext: rotated.ciphertext.toString("base64"),
|
|
139
|
+
iv: rotated.iv.toString("base64"),
|
|
140
|
+
authTag: rotated.authTag.toString("base64"),
|
|
141
|
+
encryptedDek: rotated.encryptedDek.toString("base64"),
|
|
130
142
|
kekVersion: rotated.kekVersion,
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const actor: SessionUser = {
|
|
146
|
+
id: "system",
|
|
147
|
+
tenantId: row.tenantId as TenantId,
|
|
148
|
+
roles: SYSTEM_ROLES,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const result = await executor.update(
|
|
152
|
+
{
|
|
153
|
+
id: row.id,
|
|
154
|
+
version: row.version,
|
|
155
|
+
changes: {
|
|
156
|
+
envelope: newEnvelope,
|
|
157
|
+
kekVersion: rotated.kekVersion,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
actor,
|
|
161
|
+
tdbFor(row.tenantId as TenantId),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// version_conflict == another writer (secrets.set or a parallel
|
|
165
|
+
// rotation worker) beat us. Count as "skipped" and move on — the
|
|
166
|
+
// row is already in a valid state, potentially even past target.
|
|
167
|
+
if (!result.isSuccess) {
|
|
168
|
+
if (result.error.code === "version_conflict") continue;
|
|
169
|
+
failed++;
|
|
170
|
+
ctx.log?.warn?.(`[secrets:rotate] executor rejected row ${row.id}`, {
|
|
171
|
+
code: result.error.code,
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
failed++;
|
|
177
|
+
ctx.log?.warn?.(`[secrets:rotate] failed to rotate row ${row.id}`, { err });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
migrated++;
|
|
146
181
|
}
|
|
147
|
-
|
|
182
|
+
|
|
183
|
+
if (stoppedReason === "too_many_failures") break;
|
|
184
|
+
if (batch.length < batchSize) break;
|
|
148
185
|
}
|
|
149
186
|
|
|
150
|
-
const
|
|
151
|
-
nextBatch,
|
|
152
|
-
migrateRow,
|
|
153
|
-
maxFailures,
|
|
154
|
-
deadlineAt: deadline,
|
|
155
|
-
signal: ctx.signal,
|
|
156
|
-
onRowError: (row, err) => {
|
|
157
|
-
ctx.log?.warn?.(`[secrets:rotate] failed to rotate row ${row.id}`, { err });
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const result: RotateJobResult = {
|
|
162
|
-
migrated: outcome.migrated,
|
|
163
|
-
failed: outcome.failed,
|
|
164
|
-
batchesProcessed: outcome.batchesProcessed,
|
|
165
|
-
stoppedReason: outcome.stoppedReason,
|
|
166
|
-
};
|
|
187
|
+
const result: RotateJobResult = { migrated, failed, batchesProcessed, stoppedReason };
|
|
167
188
|
ctx.log?.info?.(`[secrets:rotate] complete: ${JSON.stringify(result)}`);
|
|
168
189
|
};
|
|
@@ -25,13 +25,10 @@ import {
|
|
|
25
25
|
createDekCache,
|
|
26
26
|
createSecret,
|
|
27
27
|
type DekCache,
|
|
28
|
-
decodeStoredEnvelope,
|
|
29
28
|
decryptValue,
|
|
30
|
-
encodeStoredEnvelope,
|
|
31
29
|
encryptValue,
|
|
32
30
|
type MasterKeyProvider,
|
|
33
31
|
type SecretsContext,
|
|
34
|
-
withDekCache,
|
|
35
32
|
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
36
33
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
37
34
|
import { z } from "zod";
|
|
@@ -82,9 +79,37 @@ function resolveKey(keyOrHandle: string | { readonly name: string }): string {
|
|
|
82
79
|
return typeof keyOrHandle === "string" ? keyOrHandle : keyOrHandle.name;
|
|
83
80
|
}
|
|
84
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
|
+
|
|
85
110
|
export function createSecretsContext(opts: SecretsContextOptions): SecretsContext {
|
|
86
111
|
const { db, masterKeyProvider } = opts;
|
|
87
|
-
const provider =
|
|
112
|
+
const provider = cachedProvider(masterKeyProvider, opts.dekCache ?? createDekCache());
|
|
88
113
|
|
|
89
114
|
type SecretLookupRow = {
|
|
90
115
|
readonly id: string;
|
|
@@ -108,7 +133,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
108
133
|
if (!auditCtx) {
|
|
109
134
|
const existing = await lookup(tenantId, key);
|
|
110
135
|
if (!existing) return undefined;
|
|
111
|
-
const plaintext = await decryptValue(
|
|
136
|
+
const plaintext = await decryptValue(decodeEnvelope(existing.envelope), provider);
|
|
112
137
|
return createSecret(plaintext);
|
|
113
138
|
}
|
|
114
139
|
|
|
@@ -117,7 +142,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
117
142
|
// type doesn't widen to the transaction object cleanly.
|
|
118
143
|
const envelope = await selectTenantSecretEnvelope(tx, tenantId, key);
|
|
119
144
|
if (!envelope) return undefined;
|
|
120
|
-
const pt = await decryptValue(
|
|
145
|
+
const pt = await decryptValue(decodeEnvelope(envelope), provider);
|
|
121
146
|
|
|
122
147
|
// One event per read on its own aggregate-stream (fresh UUID as
|
|
123
148
|
// aggregateId). Avoids version-conflicts between parallel reads —
|
|
@@ -165,7 +190,13 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
165
190
|
async set(tenantId, keyOrHandle, value, setOpts = {}) {
|
|
166
191
|
const key = resolveKey(keyOrHandle);
|
|
167
192
|
const envelope = await encryptValue(value, masterKeyProvider);
|
|
168
|
-
const stored: StoredEnvelope =
|
|
193
|
+
const stored: StoredEnvelope = {
|
|
194
|
+
ciphertext: envelope.ciphertext.toString("base64"),
|
|
195
|
+
iv: envelope.iv.toString("base64"),
|
|
196
|
+
authTag: envelope.authTag.toString("base64"),
|
|
197
|
+
encryptedDek: envelope.encryptedDek.toString("base64"),
|
|
198
|
+
kekVersion: envelope.kekVersion,
|
|
199
|
+
};
|
|
169
200
|
const metadata: StoredMetadata = {
|
|
170
201
|
...(setOpts.redact ? { redactedPreview: setOpts.redact(value) } : {}),
|
|
171
202
|
...(setOpts.hint ? { hint: setOpts.hint } : {}),
|
package/src/secrets/table.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
createNumberField,
|
|
14
14
|
createTextField,
|
|
15
15
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
-
import type { StoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
|
|
17
16
|
|
|
18
17
|
// Envelope stored as a single jsonb blob. All ops are upsert-by-(tenantId, key)
|
|
19
18
|
// so there's no value in decomposing the envelope into separate columns —
|
|
@@ -23,11 +22,13 @@ import type { StoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
23
22
|
// `WHERE kek_version != currentVersion()` with an index on just that column
|
|
24
23
|
// without deserializing the jsonb. Duplicated inside envelope too — the two
|
|
25
24
|
// always stay in sync via the write path.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
};
|
|
31
32
|
|
|
32
33
|
export type StoredMetadata = {
|
|
33
34
|
readonly redactedPreview?: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import {
|
|
6
7
|
setupTestStack,
|
|
@@ -9,10 +10,7 @@ import {
|
|
|
9
10
|
unsafeCreateEntityTable,
|
|
10
11
|
unsafePushTables,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
|
-
import {
|
|
13
|
-
createLateBoundHolder,
|
|
14
|
-
createTestEnvelopeCipher,
|
|
15
|
-
} from "@cosmicdrift/kumiko-framework/testing";
|
|
13
|
+
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
16
14
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
17
15
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
18
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -51,8 +49,8 @@ const encryptionKey = randomBytes(32).toString("base64");
|
|
|
51
49
|
const TENANT: TenantId = testTenantId(1);
|
|
52
50
|
|
|
53
51
|
beforeAll(async () => {
|
|
54
|
-
const encryption =
|
|
55
|
-
const resolver = createConfigResolver({
|
|
52
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
53
|
+
const resolver = createConfigResolver({ encryption });
|
|
56
54
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
57
55
|
const baseRevoker = bound.asMassRevoker();
|
|
58
56
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import {
|
|
6
7
|
setupTestStack,
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
13
|
import {
|
|
13
14
|
createLateBoundHolder,
|
|
14
|
-
createTestEnvelopeCipher,
|
|
15
15
|
deleteRows,
|
|
16
16
|
resetTestTables,
|
|
17
17
|
updateRows,
|
|
@@ -47,8 +47,8 @@ const encryptionKey = randomBytes(32).toString("base64");
|
|
|
47
47
|
const TENANT: TenantId = testTenantId(1);
|
|
48
48
|
|
|
49
49
|
beforeAll(async () => {
|
|
50
|
-
const encryption =
|
|
51
|
-
const resolver = createConfigResolver({
|
|
50
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
51
|
+
const resolver = createConfigResolver({ encryption });
|
|
52
52
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
53
53
|
|
|
54
54
|
stack = await setupTestStack({
|
package/src/sessions/feature.ts
CHANGED
|
@@ -10,46 +10,19 @@ import { userSessionEntity } from "./schema/user-session";
|
|
|
10
10
|
import type { SessionMassRevoker } from "./session-callbacks";
|
|
11
11
|
|
|
12
12
|
export type SessionsFeatureOptions = {
|
|
13
|
-
//
|
|
14
|
-
// column triggers a mass-revoke of every live session for
|
|
15
|
-
// Industry-standard "password-change signs you out everywhere"
|
|
16
|
-
// including the session that did the change itself — the client has
|
|
13
|
+
// When wired, a successful update on the `user` entity that changes the
|
|
14
|
+
// `passwordHash` column triggers a mass-revoke of every live session for
|
|
15
|
+
// that user. Industry-standard "password-change signs you out everywhere"
|
|
16
|
+
// flow, including the session that did the change itself — the client has
|
|
17
17
|
// to re-login after a password change.
|
|
18
18
|
//
|
|
19
19
|
// Runs as an afterCommit postSave hook: the password-change commits first,
|
|
20
20
|
// then the sessions are revoked. Best-effort — if the mass-revoker throws,
|
|
21
21
|
// the password change is NOT rolled back (a password change with a stale
|
|
22
22
|
// session still wins over a user-visible error on the change itself).
|
|
23
|
-
//
|
|
24
|
-
// Default: run{Prod,Dev}App bind their own sessionMassRevoker via
|
|
25
|
-
// `bindAutoRevokeOnPasswordChange` (secure-by-default). Set this option
|
|
26
|
-
// only to supply a custom revoker — an explicit value wins over the
|
|
27
|
-
// runtime binding.
|
|
28
23
|
readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
|
|
29
24
|
};
|
|
30
25
|
|
|
31
|
-
export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
|
|
32
|
-
|
|
33
|
-
// Reads the late-bind setter off a mounted sessions feature's exports.
|
|
34
|
-
// run{Prod,Dev}App call it once the DB connection is concrete — the feature
|
|
35
|
-
// itself is constructed in app run-config long before a db exists, so the
|
|
36
|
-
// revoker can't be a constructor argument.
|
|
37
|
-
export function bindAutoRevokeFromFeature(
|
|
38
|
-
feature: FeatureDefinition,
|
|
39
|
-
): BindAutoRevokeOnPasswordChange | undefined {
|
|
40
|
-
const exports = feature.exports;
|
|
41
|
-
if (exports && typeof exports === "object" && "bindAutoRevokeOnPasswordChange" in exports) {
|
|
42
|
-
const { bindAutoRevokeOnPasswordChange } = exports as {
|
|
43
|
-
bindAutoRevokeOnPasswordChange: unknown;
|
|
44
|
-
};
|
|
45
|
-
if (typeof bindAutoRevokeOnPasswordChange === "function") {
|
|
46
|
-
// @cast-boundary exports-walk — feature.exports is untyped by design
|
|
47
|
-
return bindAutoRevokeOnPasswordChange as BindAutoRevokeOnPasswordChange;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
26
|
// The sessions feature registers the read_user_sessions table (as an
|
|
54
27
|
// unmanaged direct-write store, NOT an r.entity — see below) and the three
|
|
55
28
|
// user-facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
|
|
@@ -120,26 +93,20 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
120
93
|
// "the handler didn't touch this column", which is exactly the signal
|
|
121
94
|
// we want to skip on. Works for both direct user:update calls and any
|
|
122
95
|
// other handler that happens to write the column.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
const bindAutoRevokeOnPasswordChange: BindAutoRevokeOnPasswordChange = (revoker) => {
|
|
139
|
-
// explicit constructor option wins over the runtime binding
|
|
140
|
-
autoRevoke ??= revoker;
|
|
141
|
-
};
|
|
96
|
+
const autoRevoke = options?.autoRevokeOnPasswordChange;
|
|
97
|
+
if (autoRevoke) {
|
|
98
|
+
r.entityHook("postSave", "user", async (ctx) => {
|
|
99
|
+
// skip: brand-new user, no sessions can possibly exist yet. The
|
|
100
|
+
// initial passwordHash on a user:create would trip the second guard
|
|
101
|
+
// otherwise — every registration would do a mass-revoke roundtrip
|
|
102
|
+
// for a user who literally has no rows in user_sessions.
|
|
103
|
+
if (ctx.isNew) return;
|
|
104
|
+
// skip: handler didn't touch passwordHash, nothing to revoke
|
|
105
|
+
if (ctx.changes["passwordHash"] === undefined) return;
|
|
106
|
+
await autoRevoke(String(ctx.id));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
142
109
|
|
|
143
|
-
return { handlers, queries
|
|
110
|
+
return { handlers, queries };
|
|
144
111
|
});
|
|
145
112
|
}
|
package/src/sessions/index.ts
CHANGED
|
@@ -6,8 +6,8 @@ export {
|
|
|
6
6
|
SessionHandlers,
|
|
7
7
|
SessionQueries,
|
|
8
8
|
} from "./constants";
|
|
9
|
-
export type {
|
|
10
|
-
export {
|
|
9
|
+
export type { SessionsFeatureOptions } from "./feature";
|
|
10
|
+
export { createSessionsFeature } from "./feature";
|
|
11
11
|
export { userSessionEntity, userSessionTable } from "./schema/user-session";
|
|
12
12
|
export type {
|
|
13
13
|
SessionCallbacks,
|