@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
|
@@ -44,24 +44,34 @@ const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
|
|
|
44
44
|
|
|
45
45
|
/** Stub-SecretsContext: liest aus einer in-memory-map, matcht per
|
|
46
46
|
* qualified-name. backing:"secrets" persistiert config-Werte JSON-
|
|
47
|
-
* serialisiert — der Stub spiegelt das
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
* Fehlerpfad zu treffen: ein Credential, das der Store un-JSON-kodiert
|
|
51
|
-
* zurückgibt (Korruption oder außerhalb des backing:"secrets"-Roundtrips
|
|
52
|
-
* geschrieben) muss laut failen, nicht still Müll liefern. Ignoriert
|
|
53
|
-
* auditCtx. */
|
|
54
|
-
function stubSecrets(
|
|
55
|
-
values: Record<string, string>,
|
|
56
|
-
opts?: { readonly raw?: boolean },
|
|
57
|
-
): SecretsContext {
|
|
47
|
+
* serialisiert — der Stub spiegelt das, damit der Runtime-parseStoredSecret
|
|
48
|
+
* denselben Pfad nimmt wie gegen den echten Store. Ignoriert auditCtx. */
|
|
49
|
+
function stubSecrets(values: Record<string, string>): SecretsContext {
|
|
58
50
|
const nameOf = (k: string | { readonly name: string }): string =>
|
|
59
51
|
typeof k === "string" ? k : k.name;
|
|
60
52
|
return {
|
|
61
53
|
get: async (_tenantId, key) => {
|
|
62
54
|
const value = values[nameOf(key)];
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
return value === undefined ? undefined : createSecret(JSON.stringify(value));
|
|
56
|
+
},
|
|
57
|
+
has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
|
|
58
|
+
set: async () => undefined,
|
|
59
|
+
delete: async () => false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Wie stubSecrets, aber speichert den Wert ROH (kein JSON.stringify) — um
|
|
64
|
+
* parseStoredSecret's Fehlerpfad zu treffen: ein Credential, das der Store
|
|
65
|
+
* un-JSON-kodiert zurückgibt (Korruption oder ein außerhalb des
|
|
66
|
+
* backing:"secrets"-Roundtrips geschriebener Wert) muss laut failen, nicht
|
|
67
|
+
* still Müll liefern. */
|
|
68
|
+
function rawSecretsStub(values: Record<string, string>): SecretsContext {
|
|
69
|
+
const nameOf = (k: string | { readonly name: string }): string =>
|
|
70
|
+
typeof k === "string" ? k : k.name;
|
|
71
|
+
return {
|
|
72
|
+
get: async (_tenantId, key) => {
|
|
73
|
+
const value = values[nameOf(key)];
|
|
74
|
+
return value === undefined ? undefined : createSecret(value); // RAW, not JSON
|
|
65
75
|
},
|
|
66
76
|
has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
|
|
67
77
|
set: async () => undefined,
|
|
@@ -154,7 +164,7 @@ describe("StripeCtxRuntime.clientForCtx", () => {
|
|
|
154
164
|
// throw, not silently fall through to undefined/fallback.
|
|
155
165
|
const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
|
|
156
166
|
const ctx = stubCtx({
|
|
157
|
-
secrets:
|
|
167
|
+
secrets: rawSecretsStub({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }),
|
|
158
168
|
});
|
|
159
169
|
await expect(rt.ctx.clientForCtx(ctx)).rejects.toThrow(
|
|
160
170
|
/Invalid JSON in subscription-stripe credential/,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type SubscriptionProviderPlugin,
|
|
21
21
|
subscriptionAggregateId,
|
|
22
22
|
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
23
|
-
import type
|
|
23
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
24
24
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
25
25
|
import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
26
26
|
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
testTenantId,
|
|
32
32
|
unsafePushTables,
|
|
33
33
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
34
|
-
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
35
34
|
import { Hono } from "hono";
|
|
36
35
|
import Stripe from "stripe";
|
|
37
36
|
import { configValuesTable, createConfigFeature } from "../../config";
|
|
@@ -80,8 +79,8 @@ beforeAll(async () => {
|
|
|
80
79
|
priceToTier: PRICE_TO_TIER,
|
|
81
80
|
});
|
|
82
81
|
|
|
83
|
-
const encryption =
|
|
84
|
-
const resolver = createConfigResolver({
|
|
82
|
+
const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
83
|
+
const resolver = createConfigResolver({ encryption });
|
|
85
84
|
const masterKeyProvider = createEnvMasterKeyProvider({
|
|
86
85
|
env: {
|
|
87
86
|
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
@@ -471,8 +470,8 @@ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
|
|
|
471
470
|
|
|
472
471
|
beforeAll(async () => {
|
|
473
472
|
const stripeFeature = createSubscriptionStripeFeature({ priceToTier: PRICE_TO_TIER });
|
|
474
|
-
const encryption =
|
|
475
|
-
const resolver = createConfigResolver({
|
|
473
|
+
const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
474
|
+
const resolver = createConfigResolver({ encryption });
|
|
476
475
|
const masterKeyProvider = createEnvMasterKeyProvider({
|
|
477
476
|
env: {
|
|
478
477
|
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
@@ -358,11 +358,6 @@ describe("tags integration — multi-tenant isolation", () => {
|
|
|
358
358
|
// "Admin", not "TenantAdmin"). Default-mounted tags deny that same user.
|
|
359
359
|
describe("tags integration — openToAll access model", () => {
|
|
360
360
|
let openStack: TestStack;
|
|
361
|
-
// Dedicated stack (477/1), not the outer file's `stack` — a describe-level
|
|
362
|
-
// `--test-name-pattern` run of just this block would otherwise crash on an
|
|
363
|
-
// undefined outer `stack` instead of failing cleanly, and this block
|
|
364
|
-
// shouldn't depend on describe-execution order for its setup to exist.
|
|
365
|
-
let defaultStack: TestStack;
|
|
366
361
|
// role deliberately not in DEFAULT_TAG_ROLES nor "Admin" — proves openToAll,
|
|
367
362
|
// not an accidental role match.
|
|
368
363
|
const unprivileged = createTestUser({ roles: ["Viewer"] });
|
|
@@ -374,16 +369,10 @@ describe("tags integration — openToAll access model", () => {
|
|
|
374
369
|
await unsafeCreateEntityTable(openStack.db, tagEntity);
|
|
375
370
|
await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
|
|
376
371
|
await createEventsTable(openStack.db);
|
|
377
|
-
|
|
378
|
-
defaultStack = await setupTestStack({ features: [tagsFeature] });
|
|
379
|
-
await unsafeCreateEntityTable(defaultStack.db, tagEntity);
|
|
380
|
-
await unsafeCreateEntityTable(defaultStack.db, tagAssignmentEntity);
|
|
381
|
-
await createEventsTable(defaultStack.db);
|
|
382
372
|
});
|
|
383
373
|
|
|
384
374
|
afterAll(async () => {
|
|
385
375
|
await openStack.cleanup();
|
|
386
|
-
await defaultStack.cleanup();
|
|
387
376
|
});
|
|
388
377
|
|
|
389
378
|
test("a non-tag-role user can create, assign, list and remove", async () => {
|
|
@@ -420,7 +409,7 @@ describe("tags integration — openToAll access model", () => {
|
|
|
420
409
|
});
|
|
421
410
|
|
|
422
411
|
test("the SAME user is denied on a default-role-mounted feature", async () => {
|
|
423
|
-
const denied = await
|
|
412
|
+
const denied = await stack.http.writeErr(
|
|
424
413
|
TagsHandlers.createTag,
|
|
425
414
|
{ name: "nope" },
|
|
426
415
|
unprivileged,
|
package/src/tags/entity.ts
CHANGED
|
@@ -6,12 +6,7 @@ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/eng
|
|
|
6
6
|
export const tagEntity = createEntity({
|
|
7
7
|
table: "read_tags",
|
|
8
8
|
fields: {
|
|
9
|
-
|
|
10
|
-
// allowPlaintext silences the user-content heuristic (456/5). A tenant
|
|
11
|
-
// COULD name a tag after a person; if that becomes a real requirement,
|
|
12
|
-
// this needs a userOwned annotation + forget/export hooks in the
|
|
13
|
-
// user-data-rights pipeline (none exist for tags today).
|
|
14
|
-
name: createTextField({ required: true, maxLength: 64, allowPlaintext: "catalog-label" }),
|
|
9
|
+
name: createTextField({ required: true, maxLength: 64 }),
|
|
15
10
|
// Optional UI hint (hex or token). No enforcement — purely for rendering.
|
|
16
11
|
color: createTextField({ maxLength: 32 }),
|
|
17
12
|
// Optional entity-type scope (GitLab project-vs-group labels): empty = global
|
package/src/tags/schemas.ts
CHANGED
|
@@ -41,17 +41,10 @@ export const deleteTagPayloadSchema = z.object({
|
|
|
41
41
|
export type DeleteTagPayload = z.infer<typeof deleteTagPayloadSchema>;
|
|
42
42
|
|
|
43
43
|
// assign + remove share the (tag, entity) reference shape.
|
|
44
|
-
// entityType/entityId are app-supplied, unlike tagId (always a real tag's
|
|
45
|
-
// UUID) — tagAssignmentAggregateId joins all four with "|" to derive the
|
|
46
|
-
// stream id, so a literal "|" in either could shift tuple boundaries and
|
|
47
|
-
// collide with an unrelated (tenant, tag, entity) combination (456/3).
|
|
48
|
-
// Rejecting it here is cheaper and non-breaking compared to changing the
|
|
49
|
-
// derivation's separator, which would re-key every existing stream.
|
|
50
|
-
const NO_PIPE = /^[^|]*$/;
|
|
51
44
|
const entityTagRef = {
|
|
52
45
|
tagId: z.string().min(1).max(64),
|
|
53
|
-
entityType: z.string().min(1).max(64)
|
|
54
|
-
entityId: z.string().min(1).max(128)
|
|
46
|
+
entityType: z.string().min(1).max(64),
|
|
47
|
+
entityId: z.string().min(1).max(128),
|
|
55
48
|
} as const;
|
|
56
49
|
|
|
57
50
|
export const assignTagPayloadSchema = z.object(entityTagRef);
|
|
@@ -74,11 +74,10 @@ export async function assertConsumerHandlesNotFound(
|
|
|
74
74
|
const { tenantId } = opts;
|
|
75
75
|
let rejected = false;
|
|
76
76
|
let err: unknown;
|
|
77
|
-
const slug = `conformance-not-found-${crypto.randomUUID()}`;
|
|
78
77
|
try {
|
|
79
78
|
await consumer.resolve({
|
|
80
79
|
tenantId,
|
|
81
|
-
slug,
|
|
80
|
+
slug: "conformance-not-found-slug",
|
|
82
81
|
kind: "mail-html",
|
|
83
82
|
locale: "de",
|
|
84
83
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import type
|
|
3
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import {
|
|
6
6
|
createTestUser,
|
|
@@ -10,11 +10,7 @@ import {
|
|
|
10
10
|
unsafeCreateEntityTable,
|
|
11
11
|
unsafePushTables,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
-
import {
|
|
14
|
-
createTestEnvelopeCipher,
|
|
15
|
-
expectErrorIncludes,
|
|
16
|
-
rolesOf,
|
|
17
|
-
} from "@cosmicdrift/kumiko-framework/testing";
|
|
13
|
+
import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
14
|
import { createConfigAccessor, createConfigFeature } from "../../config";
|
|
19
15
|
import { ConfigHandlers, ConfigQueries } from "../../config/constants";
|
|
20
16
|
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
@@ -37,8 +33,8 @@ const tenantFeature = createTenantFeature();
|
|
|
37
33
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
38
34
|
|
|
39
35
|
beforeAll(async () => {
|
|
40
|
-
const encryption =
|
|
41
|
-
resolver = createConfigResolver({
|
|
36
|
+
const encryption = createEncryptionProvider(testEncryptionKey);
|
|
37
|
+
resolver = createConfigResolver({ encryption });
|
|
42
38
|
|
|
43
39
|
stack = await setupTestStack({
|
|
44
40
|
features: [configFeature, tenantFeature],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import type
|
|
3
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import {
|
|
6
6
|
createTestUser,
|
|
@@ -11,10 +11,7 @@ import {
|
|
|
11
11
|
unsafeCreateEntityTable,
|
|
12
12
|
unsafePushTables,
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
-
import {
|
|
15
|
-
createTestEnvelopeCipher,
|
|
16
|
-
expectErrorIncludes,
|
|
17
|
-
} from "@cosmicdrift/kumiko-framework/testing";
|
|
14
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
15
|
import { createConfigFeature } from "../../config";
|
|
19
16
|
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
20
17
|
import { configValuesTable } from "../../config/table";
|
|
@@ -41,8 +38,8 @@ const tenantFeature = createTenantFeature();
|
|
|
41
38
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
42
39
|
|
|
43
40
|
beforeAll(async () => {
|
|
44
|
-
const encryption =
|
|
45
|
-
resolver = createConfigResolver({
|
|
41
|
+
const encryption = createEncryptionProvider(testEncryptionKey);
|
|
42
|
+
resolver = createConfigResolver({ encryption });
|
|
46
43
|
|
|
47
44
|
stack = await setupTestStack({
|
|
48
45
|
features: [configFeature, tenantFeature, tierEngineFeature],
|
|
@@ -109,32 +109,6 @@ describe("scenario 2: field-level read access", () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
// --- find-for-auth is system-only: it returns the raw row incl. passwordHash,
|
|
113
|
-
// and every query handler is reachable via POST /api/query — so even a
|
|
114
|
-
// SystemAdmin (assignable app role) must be denied over HTTP. ---
|
|
115
|
-
|
|
116
|
-
describe("scenario 2b: find-for-auth is system-only", () => {
|
|
117
|
-
test("SystemAdmin over HTTP is denied — passwordHash must not leak", async () => {
|
|
118
|
-
await seedUser({
|
|
119
|
-
email: "auth-lookup@example.com",
|
|
120
|
-
displayName: "AuthLookup",
|
|
121
|
-
passwordHash: "must-never-leave-the-server",
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const res = await stack.http.queryWithHeaders(
|
|
125
|
-
UserQueries.findForAuth,
|
|
126
|
-
{ email: "auth-lookup@example.com" },
|
|
127
|
-
systemAdmin,
|
|
128
|
-
{},
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
132
|
-
const body = await res.text();
|
|
133
|
-
expect(body).toContain("access_denied");
|
|
134
|
-
expect(body).not.toContain("must-never-leave-the-server");
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
112
|
// --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
|
|
139
113
|
|
|
140
114
|
describe("scenario 3: self-update + field-level write access", () => {
|
|
@@ -7,12 +7,10 @@ import { userTable } from "../schema/user";
|
|
|
7
7
|
// by email OR id (exactly one, enforced by the schema). Used by the auth
|
|
8
8
|
// features via ctx.queryAs(systemUser, ...).
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// createSystemUser() carries it), so system-only keeps this handler
|
|
15
|
-
// internal to ctx.queryAs(systemUser, ...) callers.
|
|
10
|
+
// Field-level read rules allow passwordHash for the "privileged" role set,
|
|
11
|
+
// so system callers see everything; any other caller is filtered even if
|
|
12
|
+
// they somehow reach this handler. Access is also restricted to privileged
|
|
13
|
+
// — regular users or tenant admins cannot call this at all.
|
|
16
14
|
export const findForAuthQuery = defineQueryHandler({
|
|
17
15
|
name: "user:find-for-auth",
|
|
18
16
|
schema: z
|
|
@@ -26,7 +24,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
26
24
|
(v) => (v.email !== undefined) !== (v.id !== undefined),
|
|
27
25
|
{ message: "exactly one of email or id must be set" },
|
|
28
26
|
),
|
|
29
|
-
access: { roles: access.
|
|
27
|
+
access: { roles: access.privileged },
|
|
30
28
|
handler: async (query, ctx) => {
|
|
31
29
|
const where =
|
|
32
30
|
query.payload.email !== undefined
|
package/src/user/index.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
export { UserCommandSchemas } from "./command-schemas";
|
|
2
2
|
export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants";
|
|
3
|
-
export {
|
|
4
|
-
backfillUserStreamTenants,
|
|
5
|
-
type UserStreamBackfillResult,
|
|
6
|
-
} from "./db/queries/stream-tenant-backfill";
|
|
7
3
|
export { createUserFeature } from "./feature";
|
|
8
4
|
export type { UserStatus } from "./schema/user";
|
|
9
5
|
export {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
13
14
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
15
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
16
|
import {
|
|
@@ -24,11 +25,7 @@ import {
|
|
|
24
25
|
unsafeCreateEntityTable,
|
|
25
26
|
unsafePushTables,
|
|
26
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
-
import {
|
|
28
|
-
createTestEnvelopeCipher,
|
|
29
|
-
resetTestTables,
|
|
30
|
-
updateRows,
|
|
31
|
-
} from "@cosmicdrift/kumiko-framework/testing";
|
|
28
|
+
import { resetTestTables, updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
32
29
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
33
30
|
import {
|
|
34
31
|
createComplianceProfilesFeature,
|
|
@@ -103,8 +100,8 @@ const noSignedUrlProviderFeature = defineFeature("test-no-signed-url-provider",
|
|
|
103
100
|
});
|
|
104
101
|
|
|
105
102
|
beforeAll(async () => {
|
|
106
|
-
const encryption =
|
|
107
|
-
const resolver = createConfigResolver({
|
|
103
|
+
const encryption = createEncryptionProvider(testEncryptionKey);
|
|
104
|
+
const resolver = createConfigResolver({ encryption });
|
|
108
105
|
|
|
109
106
|
stack = await setupTestStack({
|
|
110
107
|
features: [
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
15
|
import { randomBytes } from "node:crypto";
|
|
16
16
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
17
18
|
import {
|
|
18
19
|
createRegistry,
|
|
19
20
|
type Registry,
|
|
@@ -39,7 +40,6 @@ import {
|
|
|
39
40
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
40
41
|
import {
|
|
41
42
|
createLateBoundHolder,
|
|
42
|
-
createTestEnvelopeCipher,
|
|
43
43
|
resetTestTables,
|
|
44
44
|
updateRows,
|
|
45
45
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -97,8 +97,8 @@ function buildFeatures() {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
beforeAll(async () => {
|
|
100
|
-
const encryption =
|
|
101
|
-
const resolver = createConfigResolver({
|
|
100
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
101
|
+
const resolver = createConfigResolver({ encryption });
|
|
102
102
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
103
103
|
|
|
104
104
|
stack = await setupTestStack({
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
15
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
16
17
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
18
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
19
|
import {
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
27
|
import {
|
|
27
28
|
createLateBoundHolder,
|
|
28
|
-
createTestEnvelopeCipher,
|
|
29
29
|
resetTestTables,
|
|
30
30
|
updateRows,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -65,8 +65,8 @@ const ALICE_EMAIL = "alice.restrict@example.com";
|
|
|
65
65
|
const ALICE_PW = "alice-pw-long-enough";
|
|
66
66
|
|
|
67
67
|
beforeAll(async () => {
|
|
68
|
-
const encryption =
|
|
69
|
-
const resolver = createConfigResolver({
|
|
68
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
69
|
+
const resolver = createConfigResolver({ encryption });
|
|
70
70
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
71
71
|
|
|
72
72
|
stack = await setupTestStack({
|
|
@@ -249,7 +249,9 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
249
249
|
r.queryHandler(myAuditLogQuery);
|
|
250
250
|
r.queryHandler(listDownloadAttemptsQuery);
|
|
251
251
|
|
|
252
|
-
// Read-only
|
|
252
|
+
// Read-only operator inspector over the GDPR read-models (SystemAdmin).
|
|
253
|
+
// Convention list/detail handlers so entityList/entityEdit resolve by QN;
|
|
254
|
+
// the screens stay inert until an app navs them (opt-in at wire time).
|
|
253
255
|
r.queryHandler(exportJobListQuery);
|
|
254
256
|
r.queryHandler(exportJobDetailQuery);
|
|
255
257
|
r.queryHandler(downloadAttemptListQuery);
|
|
@@ -63,11 +63,13 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
63
63
|
const userId = query.user.id;
|
|
64
64
|
const jobId = query.payload.jobId;
|
|
65
65
|
const tenantId = query.user.tenantId;
|
|
66
|
-
// IP
|
|
67
|
-
// befuellt) — server-trusted, anders als ein vom Client
|
|
68
|
-
// Wert
|
|
66
|
+
// IP aus dem request-scoped Kontext (von requestIdMiddleware aus
|
|
67
|
+
// x-forwarded-for befuellt) — server-trusted, anders als ein vom Client
|
|
68
|
+
// mitgeschickter Wert. UA steht nicht im RequestContext; der Audit-Row
|
|
69
|
+
// laesst sie null (best-effort, via requestId in den Server-Logs
|
|
70
|
+
// cross-referenzierbar).
|
|
69
71
|
const auditIp = requestContext.get()?.ip ?? null;
|
|
70
|
-
const auditUa
|
|
72
|
+
const auditUa: string | null = null;
|
|
71
73
|
|
|
72
74
|
// Step 1-2: job-lookup + cross-user-isolation
|
|
73
75
|
// ctx.db.raw weil tenant-agnostisch — Alice in Tenant B sucht den
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import { createSystemUser,
|
|
4
|
+
import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
7
7
|
|
|
@@ -21,6 +21,16 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
|
21
21
|
// kann abweichen).
|
|
22
22
|
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
23
23
|
|
|
24
|
+
// Stream-Tenant = die framework-injizierte tenant_id der read_users-Row. Der
|
|
25
|
+
// Reducer setzt sie aus `user.created`.tenantId, ein Rebuild rekonstruiert sie
|
|
26
|
+
// daraus — sie IST also der Stream-Key des Aggregats. Die Row direkt zu lesen
|
|
27
|
+
// (statt das created-Event zu joinen) deckt auch direkt-geseedete Rows ab.
|
|
28
|
+
async function streamTenantOf(conn: DbRunner, userId: string): Promise<TenantId | null> {
|
|
29
|
+
const row = await fetchOne<{ tenantId?: string }>(conn, userTable, { id: userId });
|
|
30
|
+
// @cast-boundary db-row — tenant_id ist eine TenantId-shaped uuid-Spalte.
|
|
31
|
+
return row?.tenantId ? (row.tenantId as TenantId) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
// `conn` ist ctx.db.raw (regulaere Handler) ODER die offene tx (forget-cleanup
|
|
25
35
|
// Sub-Tx) — so bleibt der Event-Append atomar mit dem umgebenden Write.
|
|
26
36
|
export async function updateUserLifecycle(
|
|
@@ -28,14 +38,20 @@ export async function updateUserLifecycle(
|
|
|
28
38
|
userId: string,
|
|
29
39
|
changes: Record<string, unknown>,
|
|
30
40
|
): Promise<void> {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
const streamTenantId = await streamTenantOf(conn, userId);
|
|
42
|
+
if (!streamTenantId) {
|
|
43
|
+
throw new InternalError({
|
|
44
|
+
message: `read_users row ${userId} has no tenant_id — cannot rescope lifecycle write to its stream tenant`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Rescope BEIDE Achsen auf den Stream-Tenant: der db-Arg treibt loadById +
|
|
49
|
+
// den Stream-Read, der user-Arg die Event-tenantId + Ownership. Nur beide
|
|
50
|
+
// zusammen halten created + updated auf einem Stream.
|
|
51
|
+
const tenantDb = createTenantDb(conn, streamTenantId, "tenant");
|
|
36
52
|
const result = await userExecutor.update(
|
|
37
53
|
{ id: userId, changes },
|
|
38
|
-
createSystemUser(
|
|
54
|
+
createSystemUser(streamTenantId),
|
|
39
55
|
tenantDb,
|
|
40
56
|
{ skipOptimisticLock: true },
|
|
41
57
|
);
|
|
@@ -22,11 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
24
24
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
-
import {
|
|
26
|
-
collectEncryptedFieldNames,
|
|
27
|
-
configuredEntityFieldEncryption,
|
|
28
|
-
decryptEntityFieldValues,
|
|
29
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
30
25
|
import {
|
|
31
26
|
EXT_USER_DATA,
|
|
32
27
|
type Registry,
|
|
@@ -142,9 +137,8 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
142
137
|
for (const tenantId of tenantList) {
|
|
143
138
|
const entities: UserDataExportSnippet[] = [];
|
|
144
139
|
for (const entry of hookEntries) {
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
|
|
140
|
+
const snippet = await entry.exportHook({ db, tenantId, userId });
|
|
141
|
+
if (snippet === null) continue;
|
|
148
142
|
entities.push(snippet);
|
|
149
143
|
if (snippet.fileRefs) {
|
|
150
144
|
for (const ref of snippet.fileRefs) {
|
|
@@ -175,14 +169,12 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
175
169
|
if (tenantList.length === 0 && hookEntries.length > 0) {
|
|
176
170
|
const orphanEntities: UserDataExportSnippet[] = [];
|
|
177
171
|
for (const entry of hookEntries) {
|
|
178
|
-
const
|
|
172
|
+
const snippet = await entry.exportHook({
|
|
179
173
|
db,
|
|
180
|
-
registry,
|
|
181
174
|
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
182
175
|
userId,
|
|
183
176
|
});
|
|
184
|
-
if (
|
|
185
|
-
const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
|
|
177
|
+
if (snippet === null) continue;
|
|
186
178
|
orphanEntities.push(snippet);
|
|
187
179
|
if (snippet.fileRefs) {
|
|
188
180
|
for (const ref of snippet.fileRefs) {
|
|
@@ -211,37 +203,6 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
211
203
|
};
|
|
212
204
|
}
|
|
213
205
|
|
|
214
|
-
// Art. 20 verlangt die DATEN, nicht deren Ciphertext: Export-Hooks lesen
|
|
215
|
-
// raw rows an der Executor-Decrypt-Schicht vorbei, darum entschluesselt
|
|
216
|
-
// dieser zentrale Pass encrypted entity fields nach jedem Hook. Ohne
|
|
217
|
-
// konfigurierten Cipher wird ein expliziter Marker exportiert statt den
|
|
218
|
-
// base64-Blob als "Wert" auszuliefern (leak-by-confusion).
|
|
219
|
-
const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
|
|
220
|
-
|
|
221
|
-
async function decryptSnippetFields(
|
|
222
|
-
registry: Registry,
|
|
223
|
-
hookEntityName: string,
|
|
224
|
-
snippet: UserDataExportSnippet,
|
|
225
|
-
): Promise<UserDataExportSnippet> {
|
|
226
|
-
const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
|
|
227
|
-
if (!entity) return snippet;
|
|
228
|
-
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
229
|
-
if (encryptedFields.size === 0) return snippet;
|
|
230
|
-
|
|
231
|
-
const cipher = configuredEntityFieldEncryption();
|
|
232
|
-
const rows = await Promise.all(
|
|
233
|
-
snippet.rows.map(async (row) => {
|
|
234
|
-
if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
|
|
235
|
-
const out = { ...row };
|
|
236
|
-
for (const name of encryptedFields) {
|
|
237
|
-
if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
|
|
238
|
-
}
|
|
239
|
-
return out;
|
|
240
|
-
}),
|
|
241
|
-
);
|
|
242
|
-
return { ...snippet, rows };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
206
|
// Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
|
|
246
207
|
// Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
|
|
247
208
|
// Hooks finden hier nichts (no-op).
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Provider-Wrapper lokal (Dependency-Richtung renderer-web → bundled-features
|
|
6
6
|
// verbietet test-utils-Import).
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
10
10
|
import {
|
|
11
11
|
createStaticLocaleResolver,
|
|
@@ -27,12 +27,6 @@ const stubDispatcher = {
|
|
|
27
27
|
|
|
28
28
|
const ROUTES = { requestPath: "/account/delete", confirmPath: "/account/delete/confirm" };
|
|
29
29
|
|
|
30
|
-
// renderGate mutates the shared happy-dom document's history (570/1) — reset
|
|
31
|
-
// so a later, unrelated test file doesn't inherit this suite's last path.
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
window.history.replaceState({}, "", "/");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
30
|
function renderGate(path: string, gate: ReactNode): ReturnType<typeof within> {
|
|
37
31
|
window.history.replaceState({}, "", path);
|
|
38
32
|
const { container } = render(
|
|
@@ -45,10 +45,5 @@ export function userDataRightsClient(
|
|
|
45
45
|
},
|
|
46
46
|
};
|
|
47
47
|
if (options?.publicDeletion === undefined) return base;
|
|
48
|
-
|
|
49
|
-
// that clobbers it instead of appending would silently drop future gates.
|
|
50
|
-
return {
|
|
51
|
-
...base,
|
|
52
|
-
gates: [...(base.gates ?? []), makePublicDeletionGate(options.publicDeletion)],
|
|
53
|
-
};
|
|
48
|
+
return { ...base, gates: [makePublicDeletionGate(options.publicDeletion)] };
|
|
54
49
|
}
|