@cosmicdrift/kumiko-bundled-features 0.155.1 → 0.156.1
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 +7 -6
- package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
- package/src/agent-tools/index.ts +9 -0
- package/src/agent-tools/tool-catalog.ts +130 -0
- package/src/agent-tools/tool-dispatch.ts +82 -0
- package/src/agent-tools/types.ts +38 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-gate.tsx +14 -4
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +89 -0
- package/src/auth-mfa/__tests__/totp.test.ts +2 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +29 -4
- package/src/auth-mfa/constants.ts +4 -0
- package/src/auth-mfa/handlers/disable.write.ts +2 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +4 -3
- package/src/auth-mfa/handlers/verify.write.ts +22 -12
- package/src/auth-mfa/recovery-codes.ts +2 -2
- package/src/auth-mfa/schema/user-mfa.ts +10 -0
- package/src/auth-mfa/totp.ts +18 -6
- package/src/auth-mfa/verify-factor.ts +30 -2
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -29
- package/src/auth-mfa/web/mfa-error-keys.ts +2 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +5 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +158 -0
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +171 -0
- package/src/config/__tests__/write-helpers.test.ts +23 -0
- package/src/config/handlers/set.write.ts +47 -1
- package/src/config/resolver.ts +28 -0
- package/src/config/write-helpers.ts +22 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
- package/src/delivery/feature.ts +1 -1
- package/src/file-foundation/__tests__/feature.test.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +3 -1
- package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/inbound-mail-foundation/feature.ts +3 -3
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +7 -11
- package/src/jobs/__tests__/jobs-events.integration.test.ts +37 -1
- package/src/jobs/feature.ts +1 -1
- package/src/legal-pages/feature.ts +1 -1
- package/src/mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/page-render/branding.ts +4 -3
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +77 -0
- package/src/sessions/feature.ts +2 -2
- package/src/sessions/session-checker-fail-open.test.ts +77 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +14 -7
- package/src/user-data-rights/handlers/download-by-token.query.ts +14 -7
- package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
- package/src/user-profile/__tests__/change-email.integration.test.ts +4 -0
- package/src/user-profile/feature.ts +4 -1
- package/src/user-data-rights/lib/tenant-file-provider.ts +0 -62
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// subpath (see mfa-enable-screen.tsx's import — the main entry pulls in
|
|
3
3
|
// Node-only deps like yargs/pngjs via qrcode's own package.json#browser
|
|
4
4
|
// remap, which Metro doesn't honor). Same runtime shape, just re-typed.
|
|
5
|
+
// `export *` never re-exports a default — `import QRCode from "..."`
|
|
6
|
+
// resolving here relies on the consuming app's esModuleInterop/
|
|
7
|
+
// allowSyntheticDefaultImports (which synthesizes a default from the
|
|
8
|
+
// namespace). Copy this file into apps that need it, but note that
|
|
9
|
+
// requirement — without it, the default import comes back undefined.
|
|
5
10
|
declare module "qrcode/lib/browser" {
|
|
6
11
|
export * from "qrcode";
|
|
7
12
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// piiEncrypted config keys (kumiko-platform#231/#459): the value is
|
|
2
|
+
// subject-KMS-encrypted at rest (tenant-row → tenant subject, user-row →
|
|
3
|
+
// user subject), decrypted on read for authorized readers — unlike
|
|
4
|
+
// `encrypted`/`backing="secrets"`, which the `values` query always masks
|
|
5
|
+
// regardless of role (see config/handlers/values.query.ts).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
+
import {
|
|
10
|
+
configuredPiiSubjectKms,
|
|
11
|
+
configurePiiSubjectKms,
|
|
12
|
+
InMemoryKmsAdapter,
|
|
13
|
+
isPiiCiphertext,
|
|
14
|
+
resetPiiSubjectKmsForTests,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
+
import {
|
|
17
|
+
access,
|
|
18
|
+
createTenantConfig,
|
|
19
|
+
createUserConfig,
|
|
20
|
+
defineFeature,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import {
|
|
23
|
+
createTestUser,
|
|
24
|
+
setupTestStack,
|
|
25
|
+
type TestStack,
|
|
26
|
+
TestUsers,
|
|
27
|
+
unsafePushTables,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
30
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
31
|
+
import { createConfigResolver } from "../resolver";
|
|
32
|
+
import { configValuesTable } from "../table";
|
|
33
|
+
|
|
34
|
+
const BILLING_ADDRESS_KEY = "pii-cfg-test:config:billing-address";
|
|
35
|
+
const PHONE_NUMBER_KEY = "pii-cfg-test:config:phone-number";
|
|
36
|
+
|
|
37
|
+
const piiFeature = defineFeature("pii-cfg-test", (r) => {
|
|
38
|
+
r.requires("config");
|
|
39
|
+
|
|
40
|
+
r.config({
|
|
41
|
+
keys: {
|
|
42
|
+
billingAddress: createTenantConfig("text", {
|
|
43
|
+
piiEncrypted: true,
|
|
44
|
+
read: access.all,
|
|
45
|
+
write: access.all,
|
|
46
|
+
}),
|
|
47
|
+
phoneNumber: createUserConfig("text", {
|
|
48
|
+
piiEncrypted: true,
|
|
49
|
+
read: access.all,
|
|
50
|
+
write: access.all,
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const configFeature = createConfigFeature();
|
|
57
|
+
|
|
58
|
+
let stack: TestStack;
|
|
59
|
+
const kms = new InMemoryKmsAdapter();
|
|
60
|
+
const tenantAdmin = createTestUser({ id: 2 });
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
const resolver = createConfigResolver();
|
|
64
|
+
stack = await setupTestStack({
|
|
65
|
+
features: [configFeature, piiFeature],
|
|
66
|
+
extraContext: ({ registry }) => ({
|
|
67
|
+
configResolver: resolver,
|
|
68
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
72
|
+
// One shared KMS instance for the whole file, not beforeEach/afterEach:
|
|
73
|
+
// config rows persist in the same DB across tests in this file, so a
|
|
74
|
+
// fresh adapter per test would orphan the previous test's ciphertext
|
|
75
|
+
// (KeyNotFoundError) the moment the values query resolves more than the
|
|
76
|
+
// one key that test just wrote.
|
|
77
|
+
configurePiiSubjectKms(kms);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterAll(async () => {
|
|
81
|
+
resetPiiSubjectKmsForTests();
|
|
82
|
+
await stack.cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("piiEncrypted config keys", () => {
|
|
86
|
+
test("tenant-scope value is stored as tenant-subject ciphertext, read back as plaintext", async () => {
|
|
87
|
+
await stack.http.writeOk(
|
|
88
|
+
ConfigHandlers.set,
|
|
89
|
+
{ key: BILLING_ADDRESS_KEY, value: "Musterstrasse 1, 12345 Berlin" },
|
|
90
|
+
tenantAdmin,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const row = await fetchOne<{ value: string }>(stack.db, configValuesTable, {
|
|
94
|
+
key: BILLING_ADDRESS_KEY,
|
|
95
|
+
tenantId: tenantAdmin.tenantId,
|
|
96
|
+
});
|
|
97
|
+
expect(row).toBeDefined();
|
|
98
|
+
expect(isPiiCiphertext(row?.value)).toBe(true);
|
|
99
|
+
expect(row?.value).toStartWith(`kumiko-pii:v1:tenant:${tenantAdmin.tenantId}:`);
|
|
100
|
+
|
|
101
|
+
const values = await stack.http.queryOk<
|
|
102
|
+
Record<string, { value: unknown; scope: string; source: string }>
|
|
103
|
+
>(ConfigQueries.values, {}, tenantAdmin);
|
|
104
|
+
expect(values[BILLING_ADDRESS_KEY]?.value).toBe("Musterstrasse 1, 12345 Berlin");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("user-scope value is stored as user-subject ciphertext, distinct from tenant subject", async () => {
|
|
108
|
+
await stack.http.writeOk(
|
|
109
|
+
ConfigHandlers.set,
|
|
110
|
+
{ key: PHONE_NUMBER_KEY, value: "+49 151 00000000" },
|
|
111
|
+
tenantAdmin,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const row = await fetchOne<{ value: string }>(stack.db, configValuesTable, {
|
|
115
|
+
key: PHONE_NUMBER_KEY,
|
|
116
|
+
tenantId: tenantAdmin.tenantId,
|
|
117
|
+
userId: String(tenantAdmin.id),
|
|
118
|
+
});
|
|
119
|
+
expect(row).toBeDefined();
|
|
120
|
+
expect(isPiiCiphertext(row?.value)).toBe(true);
|
|
121
|
+
expect(row?.value).toStartWith(`kumiko-pii:v1:user:${tenantAdmin.id}:`);
|
|
122
|
+
|
|
123
|
+
const values = await stack.http.queryOk<
|
|
124
|
+
Record<string, { value: unknown; scope: string; source: string }>
|
|
125
|
+
>(ConfigQueries.values, {}, tenantAdmin);
|
|
126
|
+
expect(values[PHONE_NUMBER_KEY]?.value).toBe("+49 151 00000000");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("write with scope override to system is rejected — no subject for system-row", async () => {
|
|
130
|
+
// SystemAdmin so checkScopeWriteAccess passes and the request actually
|
|
131
|
+
// reaches the piiEncrypted+scope="system" rejection, not an earlier
|
|
132
|
+
// generic "you can't write system scope" denial.
|
|
133
|
+
const err = await stack.http.writeErr(
|
|
134
|
+
ConfigHandlers.set,
|
|
135
|
+
{ key: BILLING_ADDRESS_KEY, value: "irrelevant", scope: "system" },
|
|
136
|
+
TestUsers.systemAdmin,
|
|
137
|
+
);
|
|
138
|
+
expect(err.code).toBe("unprocessable");
|
|
139
|
+
expect((err.details as { reason?: string } | undefined)?.reason).toBe("invalid_scope");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("write without a configured KMS fails loud instead of silently storing plaintext", async () => {
|
|
143
|
+
resetPiiSubjectKmsForTests();
|
|
144
|
+
expect(configuredPiiSubjectKms()).toBeUndefined();
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const res = await stack.http.write(
|
|
148
|
+
ConfigHandlers.set,
|
|
149
|
+
{ key: BILLING_ADDRESS_KEY, value: "no-kms-value" },
|
|
150
|
+
tenantAdmin,
|
|
151
|
+
);
|
|
152
|
+
expect(res.status).toBe(500);
|
|
153
|
+
} finally {
|
|
154
|
+
// Restore for any test running after this one in the file.
|
|
155
|
+
configurePiiSubjectKms(kms);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Regression guard for kumiko-framework#1187: proves the config
|
|
2
|
+
// KEK-rotation job handles a real version-N → version-N+1 rotation, not
|
|
3
|
+
// just legacy-format → envelope (see encrypted-legacy-migration.integration
|
|
4
|
+
// .test.ts for that story). Modeled directly on auth-mfa's
|
|
5
|
+
// reencrypt-job.integration.test.ts, which is the reference regression
|
|
6
|
+
// guard for this exact bug class (kumiko-framework#266 Step 8): rotation
|
|
7
|
+
// must go through executor.update() (a real event), not a raw UPDATE on
|
|
8
|
+
// the projection table — otherwise a full projection rebuild replays the
|
|
9
|
+
// OLD event and resurrects the pre-rotation kekVersion.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import {
|
|
16
|
+
access,
|
|
17
|
+
createSystemConfig,
|
|
18
|
+
createSystemUser,
|
|
19
|
+
defineFeature,
|
|
20
|
+
SYSTEM_TENANT_ID,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
23
|
+
import {
|
|
24
|
+
createEnvelopeCipher,
|
|
25
|
+
createEnvMasterKeyProvider,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
27
|
+
import {
|
|
28
|
+
setupTestStack,
|
|
29
|
+
type TestStack,
|
|
30
|
+
unsafePushTables,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { createMutableMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
|
|
33
|
+
import { createConfigFeature } from "../feature";
|
|
34
|
+
import { reencryptJob } from "../handlers/reencrypt.job";
|
|
35
|
+
import { createConfigResolver } from "../resolver";
|
|
36
|
+
import { configValueEntity, configValuesTable } from "../table";
|
|
37
|
+
|
|
38
|
+
const KEY = "kek-rot:config:secret-pass";
|
|
39
|
+
const PLAINTEXT = "rotate-me-please";
|
|
40
|
+
const PROJECTION_NAME = "config:projection:config-value-entity";
|
|
41
|
+
|
|
42
|
+
const v1Key = randomBytes(32).toString("base64");
|
|
43
|
+
const v2Key = randomBytes(32).toString("base64");
|
|
44
|
+
const mutableProvider = createMutableMasterKeyProvider(
|
|
45
|
+
createEnvMasterKeyProvider({
|
|
46
|
+
env: {
|
|
47
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
48
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
const cipher = createEnvelopeCipher(mutableProvider, {});
|
|
53
|
+
const resolver = createConfigResolver({ cipher });
|
|
54
|
+
|
|
55
|
+
const keyDef = createSystemConfig("text", {
|
|
56
|
+
encrypted: true,
|
|
57
|
+
read: access.systemAdmin,
|
|
58
|
+
write: access.systemAdmin,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const rotFeature = defineFeature("kek-rot", (r) => {
|
|
62
|
+
r.requires("config");
|
|
63
|
+
r.config({ keys: { "secret-pass": keyDef } });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
67
|
+
entityName: "config-value",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let stack: TestStack;
|
|
71
|
+
|
|
72
|
+
async function seedV1Row(): Promise<void> {
|
|
73
|
+
const envelope = await cipher.encrypt(JSON.stringify(PLAINTEXT), { tenantId: SYSTEM_TENANT_ID });
|
|
74
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
75
|
+
const tdb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
|
|
76
|
+
const result = await executor.create(
|
|
77
|
+
{ key: KEY, value: envelope, tenantId: SYSTEM_TENANT_ID, userId: null },
|
|
78
|
+
systemUser,
|
|
79
|
+
tdb,
|
|
80
|
+
);
|
|
81
|
+
if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type RawConfigRow = { value: string };
|
|
85
|
+
|
|
86
|
+
async function readRawValue(): Promise<RawConfigRow> {
|
|
87
|
+
const rows = await selectMany<RawConfigRow>(stack.db, configValuesTable, { key: KEY });
|
|
88
|
+
const row = rows[0];
|
|
89
|
+
if (!row) throw new Error("no config row");
|
|
90
|
+
return row;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const noopLog = {
|
|
94
|
+
info: () => {},
|
|
95
|
+
warn: () => {},
|
|
96
|
+
error: () => {},
|
|
97
|
+
debug: () => {},
|
|
98
|
+
child: () => noopLog,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function jobCtx(): Parameters<typeof reencryptJob>[1] {
|
|
102
|
+
return {
|
|
103
|
+
db: stack.db,
|
|
104
|
+
registry: stack.registry,
|
|
105
|
+
masterKeyProvider: mutableProvider,
|
|
106
|
+
configEncryption: cipher,
|
|
107
|
+
log: noopLog,
|
|
108
|
+
} as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
stack = await setupTestStack({
|
|
113
|
+
features: [createConfigFeature(), rotFeature],
|
|
114
|
+
masterKeyProvider: mutableProvider,
|
|
115
|
+
extraContext: { configResolver: resolver, configEncryption: cipher },
|
|
116
|
+
});
|
|
117
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
118
|
+
await seedV1Row();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterAll(async () => {
|
|
122
|
+
await stack.cleanup();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("config KEK-rotation job — version-N to version-N+1 (kumiko-framework#1187)", () => {
|
|
126
|
+
test("rotation rewraps the value onto the current KEK, and a full projection rebuild still lands on it", async () => {
|
|
127
|
+
const beforeRow = await readRawValue();
|
|
128
|
+
expect(JSON.parse(beforeRow.value).kekVersion).toBe(1);
|
|
129
|
+
|
|
130
|
+
// "ops added a new master key version and flipped CURRENT=2" —
|
|
131
|
+
// same simulate-rotation-without-restart shape auth-mfa's test uses.
|
|
132
|
+
mutableProvider.replace(
|
|
133
|
+
createEnvMasterKeyProvider({
|
|
134
|
+
env: {
|
|
135
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
136
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: v2Key,
|
|
137
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await reencryptJob({}, jobCtx());
|
|
143
|
+
|
|
144
|
+
const afterJobRow = await readRawValue();
|
|
145
|
+
expect(JSON.parse(afterJobRow.value).kekVersion).toBe(2);
|
|
146
|
+
|
|
147
|
+
// Vacuous-rebuild guard: confirm the projection name actually exists
|
|
148
|
+
// before trusting a silent no-op rebuild as a pass.
|
|
149
|
+
expect(stack.registry.getAllProjections().has(PROJECTION_NAME)).toBe(true);
|
|
150
|
+
|
|
151
|
+
const rebuildResult = await rebuildProjection(PROJECTION_NAME, {
|
|
152
|
+
db: stack.db,
|
|
153
|
+
registry: stack.registry,
|
|
154
|
+
});
|
|
155
|
+
expect(rebuildResult.eventsProcessed).toBeGreaterThan(0);
|
|
156
|
+
|
|
157
|
+
// The regression guard itself: a from-scratch rebuild replays every
|
|
158
|
+
// event for this row and must land on the V2 envelope from the
|
|
159
|
+
// rotation job's own .updated event — NOT a resurrected V1 wrap from
|
|
160
|
+
// the original seed event. Kept alive with the V1 key still present
|
|
161
|
+
// in the env on purpose: a resurrected V1 row would still decrypt
|
|
162
|
+
// cleanly, so only this version-tag check (not the decrypt below)
|
|
163
|
+
// catches the regression this issue describes.
|
|
164
|
+
const afterRebuildRow = await readRawValue();
|
|
165
|
+
expect(JSON.parse(afterRebuildRow.value).kekVersion).toBe(2);
|
|
166
|
+
|
|
167
|
+
// Decrypt-level proof, not just the version tag: the plaintext
|
|
168
|
+
// survived rotation + rebuild byte-identically.
|
|
169
|
+
expect(await resolver.get(KEY, keyDef, SYSTEM_TENANT_ID, "u1", stack.db)).toBe(PLAINTEXT);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -10,6 +10,7 @@ import type { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
|
10
10
|
import {
|
|
11
11
|
checkScopeWriteAccess,
|
|
12
12
|
hasConfigAccess,
|
|
13
|
+
resolvePiiSubject,
|
|
13
14
|
resolveScopeIds,
|
|
14
15
|
validatePattern,
|
|
15
16
|
validateScope,
|
|
@@ -101,6 +102,28 @@ describe("resolveScopeIds", () => {
|
|
|
101
102
|
});
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
describe("resolvePiiSubject (kumiko-platform#231/#459)", () => {
|
|
106
|
+
const tenant = "tenant-9" as TenantId;
|
|
107
|
+
|
|
108
|
+
test("tenant scope resolves to the tenant subject", () => {
|
|
109
|
+
expect(resolvePiiSubject(ConfigScopes.tenant, tenant, null)).toEqual({
|
|
110
|
+
kind: "tenant",
|
|
111
|
+
tenantId: tenant,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("user scope resolves to the user subject — the row's target user, not necessarily the writer", () => {
|
|
116
|
+
expect(resolvePiiSubject(ConfigScopes.user, tenant, "user-1")).toEqual({
|
|
117
|
+
kind: "user",
|
|
118
|
+
userId: "user-1",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("user scope without a userId throws instead of silently falling back to tenant", () => {
|
|
123
|
+
expect(() => resolvePiiSubject(ConfigScopes.user, tenant, null)).toThrow(/userId is null/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
104
127
|
describe("validateType", () => {
|
|
105
128
|
const numberKey = createTenantConfig("number", {});
|
|
106
129
|
const boolKey = createTenantConfig("boolean", {});
|
|
@@ -1,16 +1,27 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import {
|
|
3
|
+
configuredPiiSubjectKms,
|
|
4
|
+
encryptPiiValueForSubject,
|
|
5
|
+
type KmsContext,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
1
7
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
8
|
import {
|
|
3
9
|
ConfigScopes,
|
|
4
10
|
defineWriteHandler,
|
|
5
11
|
SYSTEM_TENANT_ID,
|
|
6
12
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
InternalError,
|
|
15
|
+
UnprocessableError,
|
|
16
|
+
writeFailure,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
8
18
|
import { z } from "zod";
|
|
9
19
|
import { requireConfigEncryption } from "../feature";
|
|
10
20
|
import { configValueEntity, configValuesTable } from "../table";
|
|
11
21
|
import {
|
|
12
22
|
findConfigRow,
|
|
13
23
|
prepareConfigWrite,
|
|
24
|
+
resolvePiiSubject,
|
|
14
25
|
validateBounds,
|
|
15
26
|
validatePattern,
|
|
16
27
|
validateScope,
|
|
@@ -47,6 +58,19 @@ export const setWrite = defineWriteHandler({
|
|
|
47
58
|
const scopeError = validateScope(scope, keyDef.scope, event.payload.key);
|
|
48
59
|
if (scopeError) return writeFailure(scopeError);
|
|
49
60
|
|
|
61
|
+
// A piiEncrypted key can never be defined with scope="system" (boot-
|
|
62
|
+
// rejected), but an admin override write can still target scope=
|
|
63
|
+
// "system" for a tenant/user-scoped key (validateScope allows any
|
|
64
|
+
// requestedScope <= definedScope) — system-row has no subject.
|
|
65
|
+
if (keyDef.piiEncrypted && scope === ConfigScopes.system) {
|
|
66
|
+
return writeFailure(
|
|
67
|
+
new UnprocessableError("invalid_scope", {
|
|
68
|
+
i18nKey: "config.errors.invalidScope",
|
|
69
|
+
details: { key: event.payload.key, definedScope: keyDef.scope, requestedScope: scope },
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
const typeError = validateType(event.payload.value, keyDef);
|
|
51
75
|
if (typeError) return writeFailure(typeError);
|
|
52
76
|
|
|
@@ -90,6 +114,28 @@ export const setWrite = defineWriteHandler({
|
|
|
90
114
|
if (keyDef.encrypted) {
|
|
91
115
|
const cipher = requireConfigEncryption(ctx, "config:write:set");
|
|
92
116
|
serialized = await cipher.encrypt(serialized, { tenantId });
|
|
117
|
+
} else if (keyDef.piiEncrypted) {
|
|
118
|
+
const kms = configuredPiiSubjectKms();
|
|
119
|
+
if (!kms) {
|
|
120
|
+
throw new InternalError({
|
|
121
|
+
message:
|
|
122
|
+
`[config:write:set] key "${event.payload.key}" declares piiEncrypted=true but no ` +
|
|
123
|
+
`PII subject-KMS is configured — pass one via runProdApp({ kms }) / ` +
|
|
124
|
+
`configurePiiSubjectKms(adapter) at boot.`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// scope="system" was already rejected above — narrow accordingly.
|
|
128
|
+
const subject = resolvePiiSubject(
|
|
129
|
+
scope as Exclude<typeof scope, "system">, // @cast-boundary runtime-checked above
|
|
130
|
+
tenantId,
|
|
131
|
+
userId,
|
|
132
|
+
);
|
|
133
|
+
const kmsCtx: KmsContext = {
|
|
134
|
+
requestId: requestContext.get()?.requestId ?? "config:write:set",
|
|
135
|
+
tenantId,
|
|
136
|
+
userId: String(event.user.id),
|
|
137
|
+
};
|
|
138
|
+
serialized = await encryptPiiValueForSubject(kms, subject, serialized, kmsCtx);
|
|
93
139
|
}
|
|
94
140
|
|
|
95
141
|
const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
|
package/src/config/resolver.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
1
2
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import {
|
|
4
|
+
configuredPiiSubjectKms,
|
|
5
|
+
decryptPiiValueForSubject,
|
|
6
|
+
type KmsContext,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
8
|
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
9
|
import type {
|
|
4
10
|
ConfigCascade,
|
|
@@ -96,6 +102,24 @@ async function decryptEncrypted(
|
|
|
96
102
|
return cipher.decrypt(raw);
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
// Decrypt gate for piiEncrypted keys, mirroring decryptEncrypted above.
|
|
106
|
+
// The subject lives inside the ciphertext (kumiko-pii:v1:<subject>:...),
|
|
107
|
+
// so unlike the write side, no scope/subject resolution is needed here —
|
|
108
|
+
// same call works for a user-row or a tenant-row value.
|
|
109
|
+
async function decryptPiiEncrypted(raw: string, qualifiedKey: string): Promise<string> {
|
|
110
|
+
const kms = configuredPiiSubjectKms();
|
|
111
|
+
if (!kms) {
|
|
112
|
+
throw new InternalError({
|
|
113
|
+
message:
|
|
114
|
+
`[config] key "${qualifiedKey}" is piiEncrypted but no PII subject-KMS is wired — the ` +
|
|
115
|
+
`boot must configure one via runProdApp({ kms }) / configurePiiSubjectKms(adapter).`,
|
|
116
|
+
i18nKey: "config.errors.cipher_missing",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const kmsCtx: KmsContext = { requestId: requestContext.get()?.requestId ?? "config:resolver" };
|
|
120
|
+
return decryptPiiValueForSubject(kms, raw, kmsCtx);
|
|
121
|
+
}
|
|
122
|
+
|
|
99
123
|
// backing="secrets" keys store their value in the secrets store (flat per
|
|
100
124
|
// (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
|
|
101
125
|
// (getWithSource + buildCascade) route the system rung here. Missing reader =
|
|
@@ -210,6 +234,8 @@ async function buildCascade(
|
|
|
210
234
|
let raw = row.value;
|
|
211
235
|
if (keyDef.encrypted) {
|
|
212
236
|
raw = await decryptEncrypted(cipher, raw, qualifiedKey);
|
|
237
|
+
} else if (keyDef.piiEncrypted) {
|
|
238
|
+
raw = await decryptPiiEncrypted(raw, qualifiedKey);
|
|
213
239
|
}
|
|
214
240
|
if (activeIndex === -1) activeIndex = levels.length;
|
|
215
241
|
levels.push({
|
|
@@ -387,6 +413,8 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
387
413
|
let raw = row.value;
|
|
388
414
|
if (keyDef.encrypted) {
|
|
389
415
|
raw = await decryptEncrypted(cipher, raw, qualifiedKey);
|
|
416
|
+
} else if (keyDef.piiEncrypted) {
|
|
417
|
+
raw = await decryptPiiEncrypted(raw, qualifiedKey);
|
|
390
418
|
}
|
|
391
419
|
return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
|
|
392
420
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// have to cross-import from another handler file.
|
|
4
4
|
|
|
5
5
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
+
import type { SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
|
|
6
7
|
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
7
8
|
import {
|
|
8
9
|
type ConfigKeyDefinition,
|
|
@@ -198,6 +199,27 @@ export function resolveScopeIds(
|
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
// The subject a piiEncrypted config value is encrypted under, for the scope
|
|
203
|
+
// actually written to (kumiko-platform#231/#459). Pure — no I/O — so a bug
|
|
204
|
+
// here (e.g. picking the writer's identity instead of the row's target,
|
|
205
|
+
// see entity fields' explicit userOwned.ownerField for why that
|
|
206
|
+
// distinction matters) is caught by a fast unit test, not only the DB-
|
|
207
|
+
// backed integration test. Caller has already rejected scope="system"
|
|
208
|
+
// (no subject exists for it).
|
|
209
|
+
export function resolvePiiSubject(
|
|
210
|
+
scope: Exclude<ConfigScope, "system">,
|
|
211
|
+
tenantId: TenantId,
|
|
212
|
+
userId: string | null,
|
|
213
|
+
): SubjectId {
|
|
214
|
+
if (scope === ConfigScopes.user) {
|
|
215
|
+
if (userId === null) {
|
|
216
|
+
throw new Error('resolvePiiSubject: scope="user" but userId is null');
|
|
217
|
+
}
|
|
218
|
+
return { kind: "user", userId };
|
|
219
|
+
}
|
|
220
|
+
return { kind: "tenant", tenantId };
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
export function validateType(
|
|
202
224
|
value: string | number | boolean,
|
|
203
225
|
keyDef: ConfigKeyDefinition,
|
|
@@ -127,7 +127,7 @@ describe("T1.5a: custom-fields events are visible in the audit log", () => {
|
|
|
127
127
|
|
|
128
128
|
const res = await listAudit({ aggregateType: "field-definition" });
|
|
129
129
|
|
|
130
|
-
// The fieldDefinition is created via
|
|
130
|
+
// The fieldDefinition is created via registerEntityCrud, so the event-type
|
|
131
131
|
// follows the entity-CRUD convention `<entity>.created` (with a dot),
|
|
132
132
|
// not the feature-emit-via-defineEvent convention used by set/cleared
|
|
133
133
|
// (`custom-fields:event:<short>`).
|
package/src/delivery/feature.ts
CHANGED
|
@@ -36,7 +36,7 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
36
36
|
r.entity("notification-preference", notificationPreferenceEntity, {
|
|
37
37
|
table: notificationPreferencesTable,
|
|
38
38
|
});
|
|
39
|
-
r.
|
|
39
|
+
r.rawTable(deliveryAttemptsTableMeta, {
|
|
40
40
|
reason: "read_side.delivery_attempt_log",
|
|
41
41
|
});
|
|
42
42
|
|
|
@@ -20,7 +20,7 @@ describe("fileFoundationFeature — shape", () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
describe("fileFoundationFeature.exports — typed handles", () => {
|
|
23
|
-
test("exposes
|
|
23
|
+
test("exposes the provider-selector handle", () => {
|
|
24
24
|
expect(fileFoundationFeature.exports.providerConfigKey).toBeDefined();
|
|
25
25
|
expect(fileFoundationFeature.exports.providerConfigKey.name).toBe(
|
|
26
26
|
"file-foundation:config:provider",
|
|
@@ -33,7 +33,9 @@ beforeAll(async () => {
|
|
|
33
33
|
// Fail loud with an actionable message when Minio is down (otherwise
|
|
34
34
|
// S3 networking errors look like provider bugs).
|
|
35
35
|
try {
|
|
36
|
-
const health = await fetch(`${endpoint.replace(/\/$/, "")}/minio/health/live
|
|
36
|
+
const health = await fetch(`${endpoint.replace(/\/$/, "")}/minio/health/live`, {
|
|
37
|
+
signal: AbortSignal.timeout(2000),
|
|
38
|
+
});
|
|
37
39
|
if (!health.ok) {
|
|
38
40
|
throw new Error(`HTTP ${health.status}`);
|
|
39
41
|
}
|
|
@@ -44,7 +44,7 @@ describe("inboundMailFoundationFeature — shape", () => {
|
|
|
44
44
|
test("SyncCursor + SeenMessage sind unmanaged (NICHT event-sourced, Plan §3.4)", () => {
|
|
45
45
|
// Drift-Pin: als r.entity würden die Tick-State-Tabellen (a) das
|
|
46
46
|
// Event-Log fluten und (b) beim Projection-Rebuild gewischt.
|
|
47
|
-
const unmanaged = Object.keys(inboundMailFoundationFeature.
|
|
47
|
+
const unmanaged = Object.keys(inboundMailFoundationFeature.rawTables);
|
|
48
48
|
expect(unmanaged.length).toBe(2);
|
|
49
49
|
});
|
|
50
50
|
});
|
|
@@ -156,12 +156,12 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
// Sync-Maschinerie: hochfrequenter Tick-State, bewusst NICHT
|
|
159
|
-
// event-sourced — r.
|
|
159
|
+
// event-sourced — r.rawTable hält die Migration-DDL, nimmt die
|
|
160
160
|
// Tabellen aber aus dem Projection-Rebuild (#494/#498-Klasse).
|
|
161
|
-
r.
|
|
161
|
+
r.rawTable(syncCursorTable, {
|
|
162
162
|
reason: "read_side.mail_sync_cursors_direct_write",
|
|
163
163
|
});
|
|
164
|
-
r.
|
|
164
|
+
r.rawTable(seenMessageTable, {
|
|
165
165
|
reason: "read_side.mail_seen_messages_direct_write",
|
|
166
166
|
});
|
|
167
167
|
|
|
@@ -82,11 +82,11 @@ function probe(host: string, port: number): Promise<boolean> {
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
const available = await probe(HOST, IMAP_PORT);
|
|
85
|
+
const available = (await probe(HOST, IMAP_PORT)) && (await probe(HOST, SMTP_PORT));
|
|
86
86
|
const liveTest = available ? test : test.skip;
|
|
87
87
|
if (!available) {
|
|
88
88
|
console.warn(
|
|
89
|
-
`imap-foundation: kein IMAP-Server auf ${HOST}:${IMAP_PORT} — Suite wird geskippt (greenmail starten)`,
|
|
89
|
+
`imap-foundation: kein IMAP/SMTP-Server auf ${HOST}:${IMAP_PORT}/${SMTP_PORT} — Suite wird geskippt (greenmail starten)`,
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -96,6 +96,7 @@ let secrets: ReturnType<typeof createSecretsContext>;
|
|
|
96
96
|
let providerRef: MutableMasterKeyProvider;
|
|
97
97
|
|
|
98
98
|
beforeAll(async () => {
|
|
99
|
+
if (!available) return;
|
|
99
100
|
const initialKp = createEnvMasterKeyProvider({
|
|
100
101
|
env: {
|
|
101
102
|
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
@@ -131,6 +132,7 @@ beforeAll(async () => {
|
|
|
131
132
|
});
|
|
132
133
|
|
|
133
134
|
afterAll(async () => {
|
|
135
|
+
if (!available) return;
|
|
134
136
|
await stack.cleanup();
|
|
135
137
|
resetPiiSubjectKmsForTests();
|
|
136
138
|
});
|
|
@@ -143,13 +145,13 @@ function adminFor(tenantNumber: number) {
|
|
|
143
145
|
});
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
function credentialJson(): string {
|
|
148
|
+
function credentialJson(passwordOverride?: string): string {
|
|
147
149
|
return JSON.stringify({
|
|
148
150
|
host: HOST,
|
|
149
151
|
port: IMAP_PORT,
|
|
150
152
|
secure: false,
|
|
151
153
|
user: USER,
|
|
152
|
-
password: PASSWORD,
|
|
154
|
+
password: passwordOverride ?? PASSWORD,
|
|
153
155
|
});
|
|
154
156
|
}
|
|
155
157
|
|
|
@@ -256,13 +258,7 @@ describe("imap-foundation — greenmail + supervisor", () => {
|
|
|
256
258
|
await secrets.set(
|
|
257
259
|
admin.tenantId,
|
|
258
260
|
inboundCredentialSecretKey(connected.accountId),
|
|
259
|
-
|
|
260
|
-
host: HOST,
|
|
261
|
-
port: IMAP_PORT,
|
|
262
|
-
secure: false,
|
|
263
|
-
user: USER,
|
|
264
|
-
password: "definitely-wrong",
|
|
265
|
-
}),
|
|
261
|
+
credentialJson("definitely-wrong"),
|
|
266
262
|
);
|
|
267
263
|
|
|
268
264
|
const supervisor = createSupervisor();
|