@cosmicdrift/kumiko-bundled-features 0.155.0 → 0.156.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 +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__/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/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 +42 -0
- package/src/sessions/feature.ts +2 -2
- 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-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
|
+
});
|
|
@@ -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();
|
package/src/jobs/feature.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
38
38
|
recommended: false,
|
|
39
39
|
});
|
|
40
40
|
r.systemScope();
|
|
41
|
-
r.
|
|
41
|
+
r.rawTable(jobRunLogsTableMeta, {
|
|
42
42
|
reason: "read_side.job_run_logs",
|
|
43
43
|
});
|
|
44
44
|
// Events-only aggregate: "jobRun" has no r.entity registration, because
|
|
@@ -50,7 +50,7 @@ export type LegalPagesWrapLayout = (opts: {
|
|
|
50
50
|
readonly title: string;
|
|
51
51
|
readonly bodyHtml: string;
|
|
52
52
|
readonly lang: string;
|
|
53
|
-
readonly slug?:
|
|
53
|
+
readonly slug?: (typeof LEGAL_ROUTES)[number]["slug"];
|
|
54
54
|
}) => string;
|
|
55
55
|
|
|
56
56
|
export type LegalPagesOptions = {
|
|
@@ -28,7 +28,7 @@ describe("mailFoundationFeature — shape", () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
describe("mailFoundationFeature.exports — typed handles", () => {
|
|
31
|
-
test("exposes
|
|
31
|
+
test("exposes the provider-selector handle", () => {
|
|
32
32
|
expect(mailFoundationFeature.exports.providerConfigKey).toBeDefined();
|
|
33
33
|
expect(mailFoundationFeature.exports.providerConfigKey.name).toBe(
|
|
34
34
|
"mail-foundation:config:provider",
|
|
@@ -73,6 +73,7 @@ export function brandingStyleBlock(tokens: BrandingTokens): string {
|
|
|
73
73
|
decls.push(`--accent:${tokens.accentColor}`);
|
|
74
74
|
}
|
|
75
75
|
decls.push(`--page-max-width:${layoutMaxWidth(tokens.layoutPreset)}`);
|
|
76
|
+
// html-ok: decls are CSS custom-property declarations (regex-validated hex color or fixed table lookup), never raw user text.
|
|
76
77
|
return `<style id="tenant-theme">:root{${decls.join(";")}}</style>`;
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -91,9 +92,9 @@ export function brandingHeaderHtml(tokens: BrandingTokens): string {
|
|
|
91
92
|
}
|
|
92
93
|
if (parts.length === 0) return "";
|
|
93
94
|
|
|
94
|
-
const
|
|
95
|
+
const innerHtml = parts.join("\n");
|
|
95
96
|
if (isSafeHttpsUrl(tokens.siteUrl)) {
|
|
96
|
-
return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${
|
|
97
|
+
return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${innerHtml}</a></header>`;
|
|
97
98
|
}
|
|
98
|
-
return `<header class="brand-header">${
|
|
99
|
+
return `<header class="brand-header">${innerHtml}</header>`;
|
|
99
100
|
}
|
|
@@ -53,7 +53,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
53
53
|
// Direct-write store like read_user_sessions: create/revoke write it, the
|
|
54
54
|
// resolver point-reads it. r.entity would make it a rebuildable projection
|
|
55
55
|
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
56
|
-
r.
|
|
56
|
+
r.rawTable(buildEntityTableMeta("api-token", apiTokenEntity), {
|
|
57
57
|
reason: "read_side.api_tokens_direct_write",
|
|
58
58
|
// create.write encrypts `name` via encryptForDirectWrite (#820).
|
|
59
59
|
piiEncryptedOnWrite: true,
|
|
@@ -511,6 +511,48 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
511
511
|
});
|
|
512
512
|
expect(asUser.status).toBe(403);
|
|
513
513
|
});
|
|
514
|
+
|
|
515
|
+
// IDOR guard: session:detail/session:list are scoped by ctx.db (TenantDb)
|
|
516
|
+
// alone, no explicit tenant predicate in the handler — an admin from a
|
|
517
|
+
// different tenant must not be able to read another tenant's session rows.
|
|
518
|
+
test("session:detail and session:list never leak sessions across tenants", async () => {
|
|
519
|
+
const TENANT_2 = testTenantId(2);
|
|
520
|
+
const h2 = makeSessionHelpers(stack, TENANT_2);
|
|
521
|
+
|
|
522
|
+
const { userId: carolId } = await h.seedUser("carol4@example.com", "pw-long-enough");
|
|
523
|
+
await updateRows(
|
|
524
|
+
stack.db,
|
|
525
|
+
tenantMembershipsTable,
|
|
526
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
527
|
+
{ userId: carolId, tenantId: TENANT },
|
|
528
|
+
);
|
|
529
|
+
const carolAsAdmin = await h.login("carol4@example.com", "pw-long-enough");
|
|
530
|
+
|
|
531
|
+
const { userId: daveId } = await h2.seedUser("dave4@example.com", "pw-long-enough");
|
|
532
|
+
await updateRows(
|
|
533
|
+
stack.db,
|
|
534
|
+
tenantMembershipsTable,
|
|
535
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
536
|
+
{ userId: daveId, tenantId: TENANT_2 },
|
|
537
|
+
);
|
|
538
|
+
const daveAsAdmin = await h2.login("dave4@example.com", "pw-long-enough");
|
|
539
|
+
|
|
540
|
+
const detailRes = await h2.authedPost("/api/query", daveAsAdmin.token, {
|
|
541
|
+
type: SessionQueries.detail,
|
|
542
|
+
payload: { id: carolAsAdmin.sid },
|
|
543
|
+
});
|
|
544
|
+
expect(detailRes.status).toBe(200);
|
|
545
|
+
const detailBody = (await detailRes.json()) as { data: unknown };
|
|
546
|
+
expect(detailBody.data).toBeNull();
|
|
547
|
+
|
|
548
|
+
const listRes = await h2.authedPost("/api/query", daveAsAdmin.token, {
|
|
549
|
+
type: SessionQueries.list,
|
|
550
|
+
payload: {},
|
|
551
|
+
});
|
|
552
|
+
expect(listRes.status).toBe(200);
|
|
553
|
+
const listBody = (await listRes.json()) as { data: Array<{ id: string }> };
|
|
554
|
+
expect(listBody.data.map((r) => r.id)).not.toContain(carolAsAdmin.sid);
|
|
555
|
+
});
|
|
514
556
|
});
|
|
515
557
|
|
|
516
558
|
// Defense-in-depth: the sessionChecker refuses a live sid once the user it
|
package/src/sessions/feature.ts
CHANGED
|
@@ -93,10 +93,10 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
93
93
|
// r.entity would make it a rebuildable implicit projection whose replay
|
|
94
94
|
// finds zero session events and swaps an empty shadow over the live
|
|
95
95
|
// table — wiping every active session on the next projection rebuild
|
|
96
|
-
// (#498/#494). r.
|
|
96
|
+
// (#498/#494). r.rawTable keeps the migration DDL but opts the
|
|
97
97
|
// table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
|
|
98
98
|
// which are direct-write stores too.
|
|
99
|
-
r.
|
|
99
|
+
r.rawTable(buildEntityTableMeta("user-session", userSessionEntity), {
|
|
100
100
|
reason: "read_side.user_sessions_direct_write",
|
|
101
101
|
// sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
|
|
102
102
|
piiEncryptedOnWrite: true,
|