@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.2
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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +205 -0
- package/src/audit/__tests__/audit.integration.test.ts +52 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/login-gates.test.ts +39 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +196 -155
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/entities.ts +3 -3
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +3 -3
- package/src/personal-access-tokens/schema/api-token.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
- package/src/sessions/feature.ts +21 -6
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/schema/user-session.ts +6 -7
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +72 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
- package/src/text-content/handlers/set.write.ts +2 -2
- package/src/text-content/seeding.ts +31 -29
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -2,8 +2,8 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
-
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
6
5
|
import { z } from "zod";
|
|
6
|
+
import { isWithinGracePeriod } from "../../shared";
|
|
7
7
|
import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
|
|
8
8
|
import { DESTRUCTION_CANCELLED_EVENT_QN } from "../constants";
|
|
9
9
|
import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
|
|
@@ -33,11 +33,7 @@ export const cancelDestructionWrite = defineWriteHandler({
|
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
const inGrace =
|
|
38
|
-
gracePeriodEnd != null &&
|
|
39
|
-
Temporal.Instant.compare(gracePeriodEnd, getTemporal().Now.instant()) > 0;
|
|
40
|
-
if (!inGrace) {
|
|
36
|
+
if (!isWithinGracePeriod(row.gracePeriodEnd)) {
|
|
41
37
|
return writeFailure(
|
|
42
38
|
new UnprocessableError("grace_period_expired", {
|
|
43
39
|
details: { reason: "grace_period_expired" },
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Package-level cases the recipe does NOT cover: unknown field at define time,
|
|
2
|
+
// and missing tenant-settings mount fails loud when currency is omitted.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
configValuesTable,
|
|
7
|
+
createConfigAccessorFactory,
|
|
8
|
+
createConfigFeature,
|
|
9
|
+
createConfigResolver,
|
|
10
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
11
|
+
import {
|
|
12
|
+
createEntity,
|
|
13
|
+
createMoneyField,
|
|
14
|
+
createSelectField,
|
|
15
|
+
createTextField,
|
|
16
|
+
defineFeature,
|
|
17
|
+
type SessionUser,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
20
|
+
import {
|
|
21
|
+
pushEntityProjectionTables,
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
testTenantId,
|
|
25
|
+
unsafeCreateEntityTable,
|
|
26
|
+
unsafePushTables,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { defineCreateWithTenantDefaults } from "../tenant-defaults";
|
|
29
|
+
|
|
30
|
+
const invoiceEntity = createEntity({
|
|
31
|
+
table: "read_invoices",
|
|
32
|
+
fields: {
|
|
33
|
+
title: createTextField({ required: true }),
|
|
34
|
+
amount: createMoneyField({ required: true }),
|
|
35
|
+
language: createSelectField({ options: ["en", "de"] as const }),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const ACCESS = { roles: ["Admin"] } as const;
|
|
40
|
+
|
|
41
|
+
test("defineCreateWithTenantDefaults throws on an unknown currency field", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
44
|
+
access: ACCESS,
|
|
45
|
+
currencyFields: ["notARealField"],
|
|
46
|
+
}),
|
|
47
|
+
).toThrow(/unknown field "notARealField"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("without tenant-settings mount", () => {
|
|
51
|
+
const invoiceFeature = defineFeature("invoice", (r) => {
|
|
52
|
+
r.entity("invoice", invoiceEntity);
|
|
53
|
+
r.writeHandler(
|
|
54
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
55
|
+
access: ACCESS,
|
|
56
|
+
currencyFields: ["amount"],
|
|
57
|
+
localeField: "language",
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const tenantId = testTenantId(1);
|
|
63
|
+
const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
|
|
64
|
+
let stack: TestStack;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
const resolver = createConfigResolver();
|
|
68
|
+
// config feature only — no createTenantSettingsFeature()
|
|
69
|
+
stack = await setupTestStack({
|
|
70
|
+
features: [createConfigFeature(), invoiceFeature],
|
|
71
|
+
extraContext: ({ registry }) => ({
|
|
72
|
+
configResolver: resolver,
|
|
73
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
78
|
+
await createEventsTable(stack.db);
|
|
79
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await stack.cleanup();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("omitting money.currency fails loud (no silent feature-default fill)", async () => {
|
|
87
|
+
const err = await stack.http.writeErr(
|
|
88
|
+
"invoice:write:invoice:create",
|
|
89
|
+
{ title: "Invoice", amount: { amount: 1000 } },
|
|
90
|
+
admin,
|
|
91
|
+
);
|
|
92
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
type ConfigKeyDefinition,
|
|
4
|
+
createTenantConfig,
|
|
5
|
+
DEFAULT_CURRENCIES,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// ISO-639-1, optional ISO-3166-1 region ("de" or "de-DE") — apps constrain
|
|
9
|
+
// further via their own i18n setup; this only guards against garbage input.
|
|
10
|
+
const LOCALE_PATTERN = { regex: "^[a-z]{2}(-[A-Z]{2})?$" } as const;
|
|
11
|
+
|
|
12
|
+
export type TenantSettingsKeyOptions = {
|
|
13
|
+
/** Selectable currencies. Default: DEFAULT_CURRENCIES (engine). */
|
|
14
|
+
readonly currencies?: readonly string[];
|
|
15
|
+
/** Fallback when a tenant never set the key. Default: "EUR". */
|
|
16
|
+
readonly defaultCurrency?: string;
|
|
17
|
+
/** Fallback when a tenant never set the key. Default: "en". */
|
|
18
|
+
readonly defaultLocale?: string;
|
|
19
|
+
/** Who may change the tenant's settings. Default: access.admin. */
|
|
20
|
+
readonly write?: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function buildTenantSettingsKeys(
|
|
24
|
+
opts: TenantSettingsKeyOptions = {},
|
|
25
|
+
): Record<string, ConfigKeyDefinition> {
|
|
26
|
+
const write = opts.write ?? access.admin;
|
|
27
|
+
return {
|
|
28
|
+
// `mask` surfaces the key in the self-populating Settings-Hub (Tenant-
|
|
29
|
+
// Audience) without a hand-written r.screen/r.nav — see
|
|
30
|
+
// samples/recipes/managed-config for the mechanism.
|
|
31
|
+
currency: createTenantConfig("select", {
|
|
32
|
+
default: opts.defaultCurrency ?? "EUR",
|
|
33
|
+
options: opts.currencies ?? DEFAULT_CURRENCIES,
|
|
34
|
+
write,
|
|
35
|
+
mask: { title: "tenant-settings.currency", icon: "coins", order: 1 },
|
|
36
|
+
}),
|
|
37
|
+
locale: createTenantConfig("text", {
|
|
38
|
+
default: opts.defaultLocale ?? "en",
|
|
39
|
+
pattern: LOCALE_PATTERN,
|
|
40
|
+
write,
|
|
41
|
+
mask: { title: "tenant-settings.locale", icon: "languages", order: 2 },
|
|
42
|
+
}),
|
|
43
|
+
} satisfies Record<string, ConfigKeyDefinition>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// tenant-settings bundle constants — feature-name + qualified config names.
|
|
2
|
+
//
|
|
3
|
+
// Motivation (solon#P19): apps kept hard-coding a single currency/locale
|
|
4
|
+
// literal per money/select field ("EUR", "de") instead of one per-tenant
|
|
5
|
+
// setting — expensive to retrofit once a second tenant needs a different
|
|
6
|
+
// default. This bundle is the one place that setting lives.
|
|
7
|
+
|
|
8
|
+
export const TENANT_SETTINGS_FEATURE_NAME = "tenant-settings";
|
|
9
|
+
|
|
10
|
+
// Qualified config key names (QN format: scope:config:name) for
|
|
11
|
+
// ctx.config(...) reads. Clients reference the object instead of magic
|
|
12
|
+
// strings (mirror tags' Handlers/Queries constants).
|
|
13
|
+
export const TenantSettingsConfig = {
|
|
14
|
+
currency: "tenant-settings:config:currency",
|
|
15
|
+
locale: "tenant-settings:config:locale",
|
|
16
|
+
} as const;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// tenant-settings — per-tenant Currency + Locale defaults.
|
|
2
|
+
//
|
|
3
|
+
// Any handler that needs a default (money-field currency, a locale-select
|
|
4
|
+
// field, ...) reads it via `ctx.config(TenantSettingsConfig.currency)` /
|
|
5
|
+
// `ctx.config(TenantSettingsConfig.locale)` — or use
|
|
6
|
+
// `defineCreateWithTenantDefaults` (tenant-defaults.ts) to wire it into an
|
|
7
|
+
// entity's create-handler without writing that plumbing by hand.
|
|
8
|
+
//
|
|
9
|
+
// No entities, no hand-written screen — `mask` on both keys makes them
|
|
10
|
+
// surface in the self-populating Settings-Hub (Tenant-Audience) instead;
|
|
11
|
+
// `buildConfigFeatureSchema` (config feature) derives the configEdit
|
|
12
|
+
// screen + nav entry from the key's scope/type/mask, same mechanism as
|
|
13
|
+
// samples/recipes/managed-config.
|
|
14
|
+
|
|
15
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { buildTenantSettingsKeys, type TenantSettingsKeyOptions } from "./config";
|
|
17
|
+
import { TENANT_SETTINGS_FEATURE_NAME } from "./constants";
|
|
18
|
+
|
|
19
|
+
export type TenantSettingsFeatureOptions = TenantSettingsKeyOptions;
|
|
20
|
+
|
|
21
|
+
export function createTenantSettingsFeature(
|
|
22
|
+
opts: TenantSettingsFeatureOptions = {},
|
|
23
|
+
): FeatureDefinition {
|
|
24
|
+
return defineFeature(TENANT_SETTINGS_FEATURE_NAME, (r) => {
|
|
25
|
+
r.requires("config");
|
|
26
|
+
r.describe(
|
|
27
|
+
"Per-tenant Currency + Locale defaults, exposed as two config keys (tenant-settings:config:currency, tenant-settings:config:locale) that surface in the self-populating Settings-Hub via `mask`. Pair with defineCreateWithTenantDefaults to auto-fill a money field's currency or a locale field on create instead of hard-coding a literal per entity.",
|
|
28
|
+
);
|
|
29
|
+
r.config({ keys: buildTenantSettingsKeys(opts) });
|
|
30
|
+
r.translations({
|
|
31
|
+
keys: {
|
|
32
|
+
"tenant-settings.currency": { de: "Standard-Währung", en: "Default Currency" },
|
|
33
|
+
"tenant-settings.locale": { de: "Standard-Sprache", en: "Default Locale" },
|
|
34
|
+
// Settings-Hub-derived configEdit screen title (buildConfigFeatureSchema) —
|
|
35
|
+
// required by the i18n boot-validator whenever a feature masks config keys.
|
|
36
|
+
"screen:tenant-settings-system.title": {
|
|
37
|
+
de: "Tenant-Einstellungen",
|
|
38
|
+
en: "Tenant Settings",
|
|
39
|
+
},
|
|
40
|
+
"screen:tenant-settings-tenant.title": {
|
|
41
|
+
de: "Tenant-Einstellungen",
|
|
42
|
+
en: "Tenant Settings",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const tenantSettingsFeature: FeatureDefinition = createTenantSettingsFeature();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { buildTenantSettingsKeys, type TenantSettingsKeyOptions } from "./config";
|
|
2
|
+
export { TENANT_SETTINGS_FEATURE_NAME, TenantSettingsConfig } from "./constants";
|
|
3
|
+
export {
|
|
4
|
+
createTenantSettingsFeature,
|
|
5
|
+
type TenantSettingsFeatureOptions,
|
|
6
|
+
tenantSettingsFeature,
|
|
7
|
+
} from "./feature";
|
|
8
|
+
export { defineCreateWithTenantDefaults } from "./tenant-defaults";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Wrapper around defineEntityCreateHandler: fills currency/locale fields from
|
|
2
|
+
// tenant-settings config when the caller omits them — instead of repeating
|
|
3
|
+
// field literals ("EUR"/"de") on every entity.
|
|
4
|
+
//
|
|
5
|
+
// buildInsertSchema always requires `currency` inside a money field (even when
|
|
6
|
+
// the field itself is optional), so the caller cannot simply omit currency
|
|
7
|
+
// without failing schema validation before the handler runs. This helper uses
|
|
8
|
+
// a schema extension that makes those sub-keys optional, instead of the
|
|
9
|
+
// generic defineEntityCreateHandler.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type AccessRule,
|
|
13
|
+
buildInsertSchema,
|
|
14
|
+
createEntityExecutor,
|
|
15
|
+
type EntityDefinition,
|
|
16
|
+
type WriteHandlerDef,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { TenantSettingsConfig } from "./constants";
|
|
20
|
+
|
|
21
|
+
const OPTIONAL_CURRENCY_MONEY = z.object({ amount: z.number(), currency: z.string().optional() });
|
|
22
|
+
|
|
23
|
+
export function defineCreateWithTenantDefaults(
|
|
24
|
+
entityName: string,
|
|
25
|
+
entity: EntityDefinition,
|
|
26
|
+
options: {
|
|
27
|
+
readonly access?: AccessRule;
|
|
28
|
+
/** Money field names whose `currency` is filled from tenant-settings when missing. */
|
|
29
|
+
readonly currencyFields?: readonly string[];
|
|
30
|
+
/** Field name (select/text) that receives the tenant locale when missing. */
|
|
31
|
+
readonly localeField?: string;
|
|
32
|
+
},
|
|
33
|
+
): WriteHandlerDef {
|
|
34
|
+
const { executor } = createEntityExecutor(entityName, entity);
|
|
35
|
+
const relax: Record<string, z.ZodTypeAny> = {};
|
|
36
|
+
for (const field of options.currencyFields ?? []) {
|
|
37
|
+
const def = entity.fields[field];
|
|
38
|
+
if (!def) throw new Error(`defineCreateWithTenantDefaults: unknown field "${field}"`);
|
|
39
|
+
const isRequired = "required" in def && def.required === true;
|
|
40
|
+
relax[field] = isRequired ? OPTIONAL_CURRENCY_MONEY : OPTIONAL_CURRENCY_MONEY.optional();
|
|
41
|
+
}
|
|
42
|
+
const baseSchema = buildInsertSchema(entity);
|
|
43
|
+
const schema = Object.keys(relax).length > 0 ? baseSchema.extend(relax) : baseSchema;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: `${entityName}:create`,
|
|
47
|
+
schema,
|
|
48
|
+
...(options.access && { access: options.access }),
|
|
49
|
+
handler: async (event, ctx) => {
|
|
50
|
+
const payload = { ...(event.payload as Record<string, unknown>) };
|
|
51
|
+
|
|
52
|
+
for (const field of options.currencyFields ?? []) {
|
|
53
|
+
const value = payload[field] as { amount?: number; currency?: string } | undefined;
|
|
54
|
+
if (value && typeof value === "object" && !value.currency) {
|
|
55
|
+
const currency = await ctx.config?.(TenantSettingsConfig.currency);
|
|
56
|
+
if (typeof currency === "string") payload[field] = { ...value, currency };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (options.localeField && !payload[options.localeField]) {
|
|
60
|
+
const locale = await ctx.config?.(TenantSettingsConfig.locale);
|
|
61
|
+
if (typeof locale === "string") payload[options.localeField] = locale;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Re-validate against the unrelaxed entity schema so config-filled
|
|
65
|
+
// values still honor select/enum field constraints (fw#1581) — and so
|
|
66
|
+
// a missing currency after failed/missing tenant-settings mount fails
|
|
67
|
+
// loud instead of inserting half-filled money rows.
|
|
68
|
+
const filled = baseSchema.parse(payload);
|
|
69
|
+
return executor.create(filled, event.user, ctx.db);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { type DbConnection, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
2
|
+
import { type DbConnection, fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { SYSTEM_TENANT_ID, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import {
|
|
6
6
|
setupTestStack,
|
|
7
7
|
type TestStack,
|
|
@@ -34,14 +34,19 @@ function read(slug: string, lang: string) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
describe("seedLegalContentFromJson", () => {
|
|
37
|
-
test("seeds all blocks into SYSTEM_TENANT_ID
|
|
37
|
+
test("seeds all blocks into SYSTEM_TENANT_ID as SYSTEM_USER", async () => {
|
|
38
38
|
const blocks: LegalContentBlock[] = [
|
|
39
39
|
{ slug: "imprint", lang: "de", title: "Impressum", body: "Marc Frost" },
|
|
40
40
|
{ slug: "imprint", lang: "en", title: "Imprint", body: "Marc Frost" },
|
|
41
41
|
];
|
|
42
42
|
await seedLegalContentFromJson(db, blocks);
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
const de = (await read("imprint", "de")) as TextBlockRow & {
|
|
45
|
+
insertedById: string;
|
|
46
|
+
modifiedById: string | null;
|
|
47
|
+
};
|
|
48
|
+
expect(de).toMatchObject({ title: "Impressum", body: "Marc Frost" });
|
|
49
|
+
expect(de.insertedById).toBe(SYSTEM_USER_ID);
|
|
45
50
|
expect(await read("imprint", "en")).toMatchObject({ title: "Imprint", body: "Marc Frost" });
|
|
46
51
|
});
|
|
47
52
|
|
|
@@ -56,6 +61,28 @@ describe("seedLegalContentFromJson", () => {
|
|
|
56
61
|
{ slug: "privacy", lang: "de", title: "Datenschutz", body: "v2 + Sub-Processor-Tabelle" },
|
|
57
62
|
];
|
|
58
63
|
await seedLegalContentFromJson(db, v2);
|
|
59
|
-
|
|
64
|
+
const row = (await read("privacy", "de")) as TextBlockRow & { modifiedById: string };
|
|
65
|
+
expect(row).toMatchObject({ body: "v2 + Sub-Processor-Tabelle" });
|
|
66
|
+
expect(row.modifiedById).toBe(SYSTEM_USER_ID);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("identical re-seed is a no-op (no update event, version/modifiedAt stable)", async () => {
|
|
70
|
+
const blocks: LegalContentBlock[] = [
|
|
71
|
+
{ slug: "terms", lang: "de", title: "AGB", body: "stable" },
|
|
72
|
+
];
|
|
73
|
+
await seedLegalContentFromJson(db, blocks);
|
|
74
|
+
const before = (await read("terms", "de")) as TextBlockRow & {
|
|
75
|
+
modifiedAt: unknown;
|
|
76
|
+
};
|
|
77
|
+
expect(before).not.toBeNull();
|
|
78
|
+
|
|
79
|
+
await seedLegalContentFromJson(db, blocks);
|
|
80
|
+
const after = (await read("terms", "de")) as TextBlockRow & { modifiedAt: unknown };
|
|
81
|
+
expect(after.version).toBe(before.version);
|
|
82
|
+
expect(after.body).toBe("stable");
|
|
83
|
+
expect(after.modifiedAt).toEqual(before.modifiedAt);
|
|
84
|
+
|
|
85
|
+
const events = await selectMany(db, eventsTable, { aggregateId: String(before.id) });
|
|
86
|
+
expect(events.map((e) => e.type)).toEqual(["text-block.created"]);
|
|
60
87
|
});
|
|
61
88
|
});
|
|
@@ -83,8 +83,8 @@ export const setWrite = defineWriteHandler({
|
|
|
83
83
|
// umgestellt werden, sonst läuft der event-store-Lookup
|
|
84
84
|
// (getStreamVersion) gegen user.tenantId statt tenantId — und findet
|
|
85
85
|
// den stream nicht → version_conflict obwohl die projection-row da ist.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
86
|
+
// Symmetric with seedTextBlock, which defaults by to
|
|
87
|
+
// createSystemUser(tenantId) so stream lookup and projection tenant match.
|
|
88
88
|
const executorUser =
|
|
89
89
|
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
|
|
90
90
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Boot-/test-seed helpers for text-content. Creates a TextBlock via the
|
|
2
|
+
// event-store executor — same path as the real set-handler, without the
|
|
3
|
+
// access check. Default ifExists="skip": only missing blocks; opt-in
|
|
4
|
+
// update for demo fixtures / legal template authority.
|
|
5
5
|
|
|
6
6
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
7
|
import {
|
|
@@ -10,12 +10,12 @@ import {
|
|
|
10
10
|
type DbConnection,
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
12
12
|
import {
|
|
13
|
+
createSystemUser,
|
|
13
14
|
type SessionUser,
|
|
14
15
|
SYSTEM_TENANT_ID,
|
|
15
16
|
type TenantId,
|
|
16
17
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
18
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
18
|
-
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
19
19
|
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
|
|
20
20
|
|
|
21
21
|
const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
|
|
@@ -28,10 +28,10 @@ export type SeedTextBlockOptions = {
|
|
|
28
28
|
readonly lang: string;
|
|
29
29
|
readonly title: string;
|
|
30
30
|
readonly body?: string | null;
|
|
31
|
-
/** V.1.4: Folder
|
|
32
|
-
* root
|
|
33
|
-
* (system-trusted),
|
|
34
|
-
* set.write
|
|
31
|
+
/** V.1.4: Folder path for visual-tree grouping. Optional + null =
|
|
32
|
+
* root node. Seed path bypasses slugSchema/folderSchema validation
|
|
33
|
+
* (system-trusted), but app builders should keep kebab-only so
|
|
34
|
+
* set.write can overwrite the seeded row later. */
|
|
35
35
|
readonly folder?: string | null;
|
|
36
36
|
readonly by?: SessionUser;
|
|
37
37
|
readonly ifExists?: SeedIfExists;
|
|
@@ -41,15 +41,11 @@ export async function seedTextBlock(
|
|
|
41
41
|
db: DbConnection,
|
|
42
42
|
opts: SeedTextBlockOptions,
|
|
43
43
|
): Promise<{ id: string }> {
|
|
44
|
-
// Default
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
// testTenantId(1), nicht opts.tenantId — explizit überschreiben.
|
|
50
|
-
const by = opts.by ?? { ...TestUsers.systemAdmin, tenantId: opts.tenantId };
|
|
51
|
-
// executor.create erwartet TenantDb — wrapping nötig damit die runtime-
|
|
52
|
-
// checks (tenant-scope-validation) greifen.
|
|
44
|
+
// Default actor is createSystemUser(tenantId) — same as config-seed —
|
|
45
|
+
// so prod boot seeds (legal pages) do not pull TestUsers / stack into
|
|
46
|
+
// the app bundle or stamp phantom test fixture IDs into the audit trail.
|
|
47
|
+
const by = opts.by ?? createSystemUser(opts.tenantId);
|
|
48
|
+
// executor.create expects TenantDb — wrap so runtime tenant-scope checks apply.
|
|
53
49
|
const tdb = createTenantDb(db, opts.tenantId, "system");
|
|
54
50
|
|
|
55
51
|
const existing = await fetchOne<TextBlockRow>(db, textBlocksTable, {
|
|
@@ -59,6 +55,7 @@ export async function seedTextBlock(
|
|
|
59
55
|
});
|
|
60
56
|
|
|
61
57
|
const folder = opts.folder ?? null;
|
|
58
|
+
const body = opts.body ?? null;
|
|
62
59
|
|
|
63
60
|
return runEventStoreSeed({
|
|
64
61
|
existing,
|
|
@@ -69,7 +66,7 @@ export async function seedTextBlock(
|
|
|
69
66
|
slug: opts.slug,
|
|
70
67
|
lang: opts.lang,
|
|
71
68
|
title: opts.title,
|
|
72
|
-
body
|
|
69
|
+
body,
|
|
73
70
|
folder,
|
|
74
71
|
tenantId: opts.tenantId,
|
|
75
72
|
},
|
|
@@ -79,9 +76,9 @@ export async function seedTextBlock(
|
|
|
79
76
|
if (!result.isSuccess) {
|
|
80
77
|
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
81
78
|
}
|
|
82
|
-
// @cast-boundary db-row: executor.create result.data
|
|
83
|
-
// inserted Drizzle
|
|
84
|
-
//
|
|
79
|
+
// @cast-boundary db-row: executor.create result.data is the
|
|
80
|
+
// inserted Drizzle row (Record<string, unknown>), projected
|
|
81
|
+
// after INSERT/RETURNING onto TextBlockRow. Runtime check below.
|
|
85
82
|
const data = result.data as Partial<TextBlockRow>;
|
|
86
83
|
if (data.id === undefined) {
|
|
87
84
|
throw new Error("seedTextBlock: executor.create did not return an id");
|
|
@@ -89,11 +86,16 @@ export async function seedTextBlock(
|
|
|
89
86
|
return { id: data.id };
|
|
90
87
|
},
|
|
91
88
|
update: async (row) => {
|
|
89
|
+
// Skip no-op updates so legal re-seed on every pod boot does not
|
|
90
|
+
// bump version/updatedAt (ETag / "last changed" / event-store growth).
|
|
91
|
+
if (row.title === opts.title && row.body === body && row.folder === folder) {
|
|
92
|
+
return { id: row.id };
|
|
93
|
+
}
|
|
92
94
|
const result = await executor.update(
|
|
93
95
|
{
|
|
94
96
|
id: row.id,
|
|
95
97
|
version: row.version,
|
|
96
|
-
changes: { title: opts.title, body
|
|
98
|
+
changes: { title: opts.title, body, folder },
|
|
97
99
|
},
|
|
98
100
|
by,
|
|
99
101
|
tdb,
|
|
@@ -113,17 +115,16 @@ export type LegalContentBlock = {
|
|
|
113
115
|
readonly body: string;
|
|
114
116
|
};
|
|
115
117
|
|
|
116
|
-
// Boot-
|
|
117
|
-
// "update"`
|
|
118
|
-
//
|
|
119
|
-
// vorgeschriebene Zusätze (z.B. Sub-Processor-Tabelle) bereits geseedete
|
|
120
|
-
// Prod-Records nie. Genau diese Entscheidung soll keine App selbst treffen.
|
|
118
|
+
// Boot-seed for legal/marketing copy from a JSON template. `ifExists:
|
|
119
|
+
// "update"` is load-bearing: the template is authoritative — otherwise
|
|
120
|
+
// legally required additions never reach already-seeded prod rows.
|
|
121
121
|
export async function seedLegalContentFromJson(
|
|
122
122
|
db: DbConnection,
|
|
123
123
|
blocks: readonly LegalContentBlock[],
|
|
124
|
-
opts: { readonly tenantId?: TenantId } = {},
|
|
124
|
+
opts: { readonly tenantId?: TenantId; readonly by?: SessionUser } = {},
|
|
125
125
|
): Promise<void> {
|
|
126
126
|
const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
|
|
127
|
+
const by = opts.by ?? createSystemUser(tenantId);
|
|
127
128
|
for (const block of blocks) {
|
|
128
129
|
await seedTextBlock(db, {
|
|
129
130
|
tenantId,
|
|
@@ -132,6 +133,7 @@ export async function seedLegalContentFromJson(
|
|
|
132
133
|
title: block.title,
|
|
133
134
|
body: block.body,
|
|
134
135
|
ifExists: "update",
|
|
136
|
+
by,
|
|
135
137
|
});
|
|
136
138
|
}
|
|
137
139
|
}
|
|
@@ -28,7 +28,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
28
28
|
),
|
|
29
29
|
access: { roles: access.system },
|
|
30
30
|
handler: async (query, ctx) => {
|
|
31
|
-
const where =
|
|
31
|
+
const where: { email: string } | { id: string } =
|
|
32
32
|
query.payload.email !== undefined
|
|
33
33
|
? { email: query.payload.email }
|
|
34
34
|
: { id: query.payload.id as string }; // @cast-boundary engine-payload
|
package/src/user/seeding.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
19
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
20
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
// kumiko-lint-ignore cross-feature-import shares the postSave-hooks fixture contract with tenant/seeding (#1478)
|
|
22
|
+
import { fireEntityPostSave, type SeedTenantHooks } from "../tenant/seeding";
|
|
21
23
|
import { userEntity, userTable } from "./schema/user";
|
|
22
24
|
|
|
23
25
|
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
@@ -52,6 +54,11 @@ export type SeedUserOptions = {
|
|
|
52
54
|
export async function seedUser(
|
|
53
55
|
db: DbConnection,
|
|
54
56
|
options: SeedUserOptions,
|
|
57
|
+
// Optional, append-only — same rationale as tenant/seeding's seedTenant
|
|
58
|
+
// hooks param (#1478): fires user's postSave hooks (e.g. a Welcome-
|
|
59
|
+
// notification hook keyed off `allOf: "user"`) for fixtures/self-signup
|
|
60
|
+
// that opt in. Omit to keep today's hook-less behavior.
|
|
61
|
+
hooks?: SeedTenantHooks,
|
|
55
62
|
): Promise<{ id: string }> {
|
|
56
63
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
57
64
|
// executor.create erwartet eine TenantDb (mit .insert()-API). User
|
|
@@ -81,6 +88,7 @@ export async function seedUser(
|
|
|
81
88
|
`seedUser failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
82
89
|
);
|
|
83
90
|
}
|
|
91
|
+
await fireEntityPostSave(hooks, "user:seed", result.data);
|
|
84
92
|
return { id: extractId(result.data, "seedUser") };
|
|
85
93
|
}
|
|
86
94
|
|
|
@@ -20,7 +20,7 @@ Dieses Dokument bündelt die **technischen Fakten** zur DSGVO-Pipeline
|
|
|
20
20
|
|---------|-------------------|--------------|--------------|
|
|
21
21
|
| **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.appendEvent` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
|
|
22
22
|
| **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
|
|
23
|
-
| **Art. 15 + 20** | `GET /user-export/by-token
|
|
23
|
+
| **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
|
|
24
24
|
| **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
|
|
25
25
|
| **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
|
|
26
26
|
| **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
|
|
@@ -22,6 +22,8 @@ import { createSessionsFeature } from "../../sessions";
|
|
|
22
22
|
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
23
23
|
import { createUserFeature } from "../../user/feature";
|
|
24
24
|
import { createUserDataRightsFeature } from "../feature";
|
|
25
|
+
import { listDownloadAttemptsQuery } from "../handlers/list-download-attempts.query";
|
|
26
|
+
import { myAuditLogQuery } from "../handlers/my-audit-log.query";
|
|
25
27
|
import { downloadAttemptEntity, downloadAttemptsTable } from "../schema/download-attempt";
|
|
26
28
|
|
|
27
29
|
const MY_AUDIT = "user-data-rights:query:my-audit-log";
|
|
@@ -158,6 +160,69 @@ describe("my-audit-log", () => {
|
|
|
158
160
|
});
|
|
159
161
|
});
|
|
160
162
|
|
|
163
|
+
describe("my-audit-log — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
164
|
+
test("from/to filter works without deleting globalThis.Temporal for handler-internal state", async () => {
|
|
165
|
+
// Seed with Temporal intact — only the handler call runs ambient-free,
|
|
166
|
+
// mirroring the audit feature's #1525 regression test (going through
|
|
167
|
+
// full HTTP dispatch would also break buildHandlerContext's unrelated
|
|
168
|
+
// ctx.tz setup, which isn't what this pins down).
|
|
169
|
+
await seedEvent(alice.id, tenantA, "user.x", { from: "range-test" });
|
|
170
|
+
|
|
171
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
172
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
173
|
+
try {
|
|
174
|
+
const res = await myAuditLogQuery.handler(
|
|
175
|
+
{
|
|
176
|
+
payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
|
|
177
|
+
user: alice,
|
|
178
|
+
} as Parameters<typeof myAuditLogQuery.handler>[0],
|
|
179
|
+
{ db: { raw: stack.db } } as unknown as Parameters<typeof myAuditLogQuery.handler>[1],
|
|
180
|
+
);
|
|
181
|
+
expect(res.rows.length).toBeGreaterThanOrEqual(1);
|
|
182
|
+
} finally {
|
|
183
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
184
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("list-download-attempts — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
190
|
+
test("from/to filter works without relying on globalThis.Temporal", async () => {
|
|
191
|
+
const T = await import("@cosmicdrift/kumiko-framework/time");
|
|
192
|
+
const now = T.getTemporal().Now.instant();
|
|
193
|
+
await seedRows(stack.db, downloadAttemptsTable, [
|
|
194
|
+
{
|
|
195
|
+
id: "33333333-3333-4333-8333-333333333333",
|
|
196
|
+
tenantId: tenantA,
|
|
197
|
+
result: "notFound",
|
|
198
|
+
via: "token",
|
|
199
|
+
tokenHash: "abc",
|
|
200
|
+
jobId: null,
|
|
201
|
+
attemptedByUserId: null,
|
|
202
|
+
ip: "1.2.3.4",
|
|
203
|
+
userAgent: "test",
|
|
204
|
+
attemptedAt: now,
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
209
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
210
|
+
try {
|
|
211
|
+
const res = await listDownloadAttemptsQuery.handler(
|
|
212
|
+
{
|
|
213
|
+
payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
|
|
214
|
+
user: admin,
|
|
215
|
+
} as Parameters<typeof listDownloadAttemptsQuery.handler>[0],
|
|
216
|
+
{ db: stack.db } as unknown as Parameters<typeof listDownloadAttemptsQuery.handler>[1],
|
|
217
|
+
);
|
|
218
|
+
expect(res.rows.length).toBeGreaterThanOrEqual(1);
|
|
219
|
+
} finally {
|
|
220
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
221
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
161
226
|
describe("download-attempt retention :: disk-bomb-Schutz bei Brute-Force", () => {
|
|
162
227
|
test("Entity-Default ist 90d hardDelete (kein unbounded growth)", () => {
|
|
163
228
|
expect(downloadAttemptEntity.retention).toBeDefined();
|