@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.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/CHANGELOG.md +91 -0
- package/package.json +22 -13
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +32 -2
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +34 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +44 -4
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +10 -12
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +90 -1
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +42 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-tenant.query.ts +56 -0
- package/src/text-content/handlers/set.write.ts +1 -1
- package/src/text-content/web/client-plugin.ts +113 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +23 -13
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +310 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +333 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// requireSecretsContext-Surface-Tests (S2.U3 Atom 3b.fix2).
|
|
2
|
+
//
|
|
3
|
+
// Pinst dass `requireSecretsContext` mit dem schmalen FileProviderContext-
|
|
4
|
+
// Surface funktioniert — nicht nur mit voller HandlerContext.
|
|
5
|
+
// Regression-Pin fuer den latenten Worker-Pfad-Bug:
|
|
6
|
+
// - Vor 3b.fix wurde `ctx as unknown as HandlerContext` durchgereicht
|
|
7
|
+
// - Im Worker-Pfad ist `ctx._userId` undefined (dispatcher setzt es nur
|
|
8
|
+
// im request-Pfad), also throw `_userId missing`
|
|
9
|
+
// - Fix: Worker-Wrap setzt explizit `_userId: SYSTEM_USER_ID`
|
|
10
|
+
//
|
|
11
|
+
// Der Test inszeniert beide Surfaces (HandlerContext-shape via type-assert
|
|
12
|
+
// + FileProviderContext-shape direkt) und prueft dass beide den happy-path
|
|
13
|
+
// + den fehlt-_userId-throw durchlaufen.
|
|
14
|
+
|
|
15
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
17
|
+
import { describe, expect, test, vi } from "vitest";
|
|
18
|
+
import { requireSecretsContext } from "../feature";
|
|
19
|
+
|
|
20
|
+
function makeRawSecretsContext(): SecretsContext {
|
|
21
|
+
return {
|
|
22
|
+
get: vi.fn(),
|
|
23
|
+
set: vi.fn(),
|
|
24
|
+
delete: vi.fn(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("requireSecretsContext :: FileProviderContext surface", () => {
|
|
29
|
+
test("succeeds with secrets + _userId present (Worker-Pfad mit SYSTEM_USER_ID)", () => {
|
|
30
|
+
const fileProviderCtx = {
|
|
31
|
+
secrets: makeRawSecretsContext(),
|
|
32
|
+
_userId: SYSTEM_USER_ID,
|
|
33
|
+
};
|
|
34
|
+
expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("throws when _userId is missing (latenter Worker-Bug pre-3b.fix)", () => {
|
|
38
|
+
// Pinst die Falle die der 3b.fix abfaengt: wenn ein Provider-Plugin
|
|
39
|
+
// `requireSecretsContext` ruft und der ctx kein _userId hat (z.B. weil
|
|
40
|
+
// ein r.job-Wrap das vergessen hat zu setzen), faellt es FRUEH mit
|
|
41
|
+
// einer klaren Fehlermeldung um — nicht silent broken.
|
|
42
|
+
const fileProviderCtx = {
|
|
43
|
+
secrets: makeRawSecretsContext(),
|
|
44
|
+
// _userId absichtlich undefined
|
|
45
|
+
};
|
|
46
|
+
expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).toThrow(/_userId missing/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("throws when secrets is missing (boot-Misconfig)", () => {
|
|
50
|
+
const fileProviderCtx = {
|
|
51
|
+
_userId: SYSTEM_USER_ID,
|
|
52
|
+
// secrets absichtlich undefined
|
|
53
|
+
};
|
|
54
|
+
expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).toThrow(
|
|
55
|
+
/ctx\.secrets missing/,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("audit-userId reaches secrets.get when call is delegated", async () => {
|
|
60
|
+
// Pinst den Audit-Pfad: wenn ein Plugin secrets.get(...) aufruft, kommt
|
|
61
|
+
// _userId als audit.userId ohne Override durch. Das ist der Grund warum
|
|
62
|
+
// SYSTEM_USER_ID nicht durch einen ad-hoc-magic-string ersetzt werden
|
|
63
|
+
// darf — sonst wird der Audit-Trail inkonsistent.
|
|
64
|
+
const raw = makeRawSecretsContext();
|
|
65
|
+
const ctx = {
|
|
66
|
+
secrets: raw,
|
|
67
|
+
_userId: SYSTEM_USER_ID,
|
|
68
|
+
};
|
|
69
|
+
const wrapped = requireSecretsContext(ctx, "user-data-rights:run-export-jobs");
|
|
70
|
+
await wrapped.get(
|
|
71
|
+
"tenant-x" as Parameters<SecretsContext["get"]>[0],
|
|
72
|
+
"any-key" as unknown as Parameters<SecretsContext["get"]>[1],
|
|
73
|
+
);
|
|
74
|
+
// Erste-Aufruf-args: [tenantId, key, audit-Object]
|
|
75
|
+
const audit = vi.mocked(raw.get).mock.calls[0]?.[2];
|
|
76
|
+
expect(audit).toEqual({
|
|
77
|
+
userId: SYSTEM_USER_ID,
|
|
78
|
+
handlerName: "user-data-rights:run-export-jobs",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/secrets/feature.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
defineFeature,
|
|
3
|
-
type FeatureDefinition,
|
|
4
|
-
type HandlerContext,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
3
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
4
|
import { deleteWrite } from "./handlers/delete.write";
|
|
@@ -24,7 +20,15 @@ export { type StoredEnvelope, type StoredMetadata, tenantSecretsTable } from "./
|
|
|
24
20
|
// wraps that raw context so every `.get(...)` call auto-includes the
|
|
25
21
|
// current user + handler as audit metadata — feature code can't forget
|
|
26
22
|
// to log the read (silent bypass of audit was the v1 gap).
|
|
27
|
-
|
|
23
|
+
//
|
|
24
|
+
// Surface bewusst minimal: HandlerContext-Pfade (Set/Delete-Handler) +
|
|
25
|
+
// FileProviderContext-Pfade (S3-Plugin im Worker) liefern dieselben
|
|
26
|
+
// zwei Felder, also reicht die schmale ctx-shape — kein voller
|
|
27
|
+
// HandlerContext-Import noetig.
|
|
28
|
+
export function requireSecretsContext(
|
|
29
|
+
ctx: { readonly secrets?: SecretsContext; readonly _userId?: string | undefined },
|
|
30
|
+
handlerName: string,
|
|
31
|
+
): SecretsContext {
|
|
28
32
|
if (!ctx.secrets) {
|
|
29
33
|
throw new InternalError({
|
|
30
34
|
message:
|
|
@@ -51,7 +51,7 @@ export type RotateJobResult = {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
54
|
-
const payload = rawPayload as RotateJobPayload;
|
|
54
|
+
const payload = rawPayload as RotateJobPayload; // @cast-boundary engine-payload
|
|
55
55
|
if (!ctx.masterKeyProvider) {
|
|
56
56
|
throw new InternalError({
|
|
57
57
|
message:
|
|
@@ -64,7 +64,7 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
|
|
|
64
64
|
message: "[secrets:rotate] ctx.db missing — job context requires a database connection.",
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
|
-
const db = ctx.db as DbConnection;
|
|
67
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
68
68
|
const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
69
69
|
const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
70
70
|
const deadline = payload.maxDurationMs
|
|
@@ -5,6 +5,10 @@ export const SESSIONS_FEATURE = "sessions" as const;
|
|
|
5
5
|
export const SessionHandlers = {
|
|
6
6
|
revoke: "sessions:write:user-session:revoke",
|
|
7
7
|
revokeAllOthers: "sessions:write:user-session:revoke-all-others",
|
|
8
|
+
/** Privileged: System-Caller (cross-feature) revokes ALL live sessions
|
|
9
|
+
* fuer einen User. Genutzt von user-data-rights:restrict-account
|
|
10
|
+
* (DSGVO Art. 18 Account-Freeze). */
|
|
11
|
+
revokeAllForUser: "sessions:write:user-session:revoke-all-for-user",
|
|
8
12
|
} as const;
|
|
9
13
|
|
|
10
14
|
export const SessionQueries = {
|
package/src/sessions/feature.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { cleanupJob } from "./handlers/cleanup.job";
|
|
|
3
3
|
import { listQuery } from "./handlers/list.query";
|
|
4
4
|
import { mineQuery } from "./handlers/mine.query";
|
|
5
5
|
import { revokeWrite } from "./handlers/revoke.write";
|
|
6
|
+
import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
|
|
6
7
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
7
8
|
import { userSessionEntity } from "./schema/user-session";
|
|
8
9
|
import type { SessionMassRevoker } from "./session-callbacks";
|
|
@@ -42,7 +43,9 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
42
43
|
const handlers = {
|
|
43
44
|
revoke: r.writeHandler(revokeWrite),
|
|
44
45
|
revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
|
|
46
|
+
revokeAllForUser: r.writeHandler(revokeAllForUserWrite),
|
|
45
47
|
};
|
|
48
|
+
r.exposesApi("sessions.revokeAllForUser");
|
|
46
49
|
|
|
47
50
|
const queries = {
|
|
48
51
|
mine: r.queryHandler(mineQuery),
|
|
@@ -39,13 +39,13 @@ export type SessionCleanupResult = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
42
|
-
const payload = rawPayload as SessionCleanupPayload;
|
|
42
|
+
const payload = rawPayload as SessionCleanupPayload; // @cast-boundary engine-payload
|
|
43
43
|
if (!ctx.db) {
|
|
44
44
|
throw new InternalError({
|
|
45
45
|
message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
-
const db = ctx.db as DbConnection;
|
|
48
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
49
49
|
|
|
50
50
|
// Coerce-and-validate: BullMQ payloads arrive as opaque JSON, so TS types
|
|
51
51
|
// don't survive. Guard before the value is interpolated into SQL.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
3
|
+
import { Temporal } from "temporal-polyfill";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { userSessionTable } from "../schema/user-session";
|
|
6
|
+
|
|
7
|
+
// Mass-revoke ALL live sessions for a target user — privileged-only.
|
|
8
|
+
// Used by user-data-rights:restrict-account zur Account-Freeze
|
|
9
|
+
// (DSGVO Art. 18) sowie potenziell anderen ops-flows ("ban user",
|
|
10
|
+
// "compromised account"). Im Gegensatz zu revoke-all-others wird
|
|
11
|
+
// die ggf. aufrufende Session ebenfalls revoked — Caller ist System
|
|
12
|
+
// (cron/operator/cross-feature), nicht der Endnutzer selbst.
|
|
13
|
+
//
|
|
14
|
+
// Tenant-scope: das userSession-Schema persistiert tenantId pro Row
|
|
15
|
+
// (User kann mehrere Sessions in mehreren Tenants haben). Wir
|
|
16
|
+
// revoken cross-tenant, weil "Account-Restriction" eine globale
|
|
17
|
+
// User-Aussage ist (Forget-Pfad ist auch global, sieht User-Entity-
|
|
18
|
+
// special-Doc). UPDATE filtert nur auf userId.
|
|
19
|
+
export const revokeAllForUserWrite = defineWriteHandler({
|
|
20
|
+
name: "user-session:revoke-all-for-user",
|
|
21
|
+
schema: z.object({
|
|
22
|
+
userId: z.string().min(1),
|
|
23
|
+
}),
|
|
24
|
+
access: { roles: access.privileged },
|
|
25
|
+
handler: async (event, ctx) => {
|
|
26
|
+
const updated = await ctx.db.raw
|
|
27
|
+
.update(userSessionTable)
|
|
28
|
+
.set({ revokedAt: Temporal.Now.instant() })
|
|
29
|
+
.where(
|
|
30
|
+
and(
|
|
31
|
+
eq(userSessionTable["userId"], event.payload.userId),
|
|
32
|
+
isNull(userSessionTable["revokedAt"]),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
.returning();
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
isSuccess: true as const,
|
|
39
|
+
data: { count: updated.length, userId: event.payload.userId },
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// step-dispatcher — bundled-feature that drains deferred Tier-2 step
|
|
2
|
+
// requests (webhook.send, mail.send, ...) after their TX commits.
|
|
3
|
+
//
|
|
4
|
+
// Listens on the `kumiko:system:step.dispatch-requested` system event
|
|
5
|
+
// (registry-bypassed, see append-event-core.ts SYSTEM_EVENT_PREFIX).
|
|
6
|
+
// Performs the side-effect and emits `kumiko:system:step.dispatched`
|
|
7
|
+
// or `kumiko:system:step.dispatch-failed` back onto the same stream so
|
|
8
|
+
// the audit trail lives in the event log only — no separate status table.
|
|
9
|
+
|
|
10
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { type MailSpec, performMailDispatch } from "./mail-runner";
|
|
12
|
+
import { performWebhookDispatch, type WebhookSpec } from "./webhook-runner";
|
|
13
|
+
|
|
14
|
+
export const STEP_DISPATCH_AGGREGATE_TYPE = "step-dispatch";
|
|
15
|
+
export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
|
|
16
|
+
export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
|
|
17
|
+
export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
|
|
18
|
+
|
|
19
|
+
type DispatchRequestedPayload =
|
|
20
|
+
| {
|
|
21
|
+
readonly stepKind: "webhook.send";
|
|
22
|
+
readonly spec: WebhookSpec;
|
|
23
|
+
readonly retry?: { readonly times: number; readonly backoff: "exponential" | "linear" };
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
readonly stepKind: "mail.send";
|
|
27
|
+
readonly spec: MailSpec;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function createStepDispatcherFeature(): FeatureDefinition {
|
|
31
|
+
return defineFeature("step-dispatcher", (r) => {
|
|
32
|
+
r.systemScope();
|
|
33
|
+
|
|
34
|
+
r.multiStreamProjection({
|
|
35
|
+
name: "step-dispatcher",
|
|
36
|
+
apply: {
|
|
37
|
+
[STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
|
|
38
|
+
const payload = event.payload as DispatchRequestedPayload;
|
|
39
|
+
const result =
|
|
40
|
+
payload.stepKind === "webhook.send"
|
|
41
|
+
? await performWebhookDispatch(payload.spec)
|
|
42
|
+
: await performMailDispatch(payload.spec);
|
|
43
|
+
if (result.ok) {
|
|
44
|
+
await ctx.unsafeAppendEvent({
|
|
45
|
+
aggregateId: event.aggregateId,
|
|
46
|
+
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
47
|
+
type: STEP_DISPATCHED_TYPE,
|
|
48
|
+
payload: { stepKind: payload.stepKind, status: result.status },
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
await ctx.unsafeAppendEvent({
|
|
52
|
+
aggregateId: event.aggregateId,
|
|
53
|
+
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
54
|
+
type: STEP_DISPATCH_FAILED_TYPE,
|
|
55
|
+
payload: { stepKind: payload.stepKind, error: result.error, attempt: 1 },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { createStepDispatcherFeature, STEP_DISPATCH_AGGREGATE_TYPE } from "./feature";
|
|
2
|
+
export {
|
|
3
|
+
type MailDispatchResult,
|
|
4
|
+
type MailSpec,
|
|
5
|
+
mailSpecSchema,
|
|
6
|
+
performMailDispatch,
|
|
7
|
+
setMailRunner,
|
|
8
|
+
} from "./mail-runner";
|
|
9
|
+
export {
|
|
10
|
+
performWebhookDispatch,
|
|
11
|
+
setWebhookFetch,
|
|
12
|
+
setWebhookSecretResolver,
|
|
13
|
+
type WebhookDispatchResult,
|
|
14
|
+
type WebhookSpec,
|
|
15
|
+
webhookSpecSchema,
|
|
16
|
+
} from "./webhook-runner";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Mail execution logic — separated from feature.ts and tests-injectable.
|
|
2
|
+
// Production wiring (mail-foundation transport) is a follow-up; the
|
|
3
|
+
// default impl throws so a missing setMailRunner is loud, not silent.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
export const mailSpecSchema = z.object({
|
|
8
|
+
to: z.union([z.string(), z.array(z.string())]),
|
|
9
|
+
subject: z.string(),
|
|
10
|
+
body: z.string(),
|
|
11
|
+
from: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type MailSpec = z.infer<typeof mailSpecSchema>;
|
|
15
|
+
|
|
16
|
+
export type MailDispatchResult =
|
|
17
|
+
| { readonly ok: true; readonly status: number }
|
|
18
|
+
| { readonly ok: false; readonly error: string };
|
|
19
|
+
|
|
20
|
+
let mailRunner: (spec: MailSpec) => Promise<MailDispatchResult> = async () => ({
|
|
21
|
+
ok: false,
|
|
22
|
+
error:
|
|
23
|
+
"no mail-runner configured — call setMailRunner() with a mail-foundation transport adapter",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult>): void {
|
|
27
|
+
mailRunner = fn;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult> {
|
|
31
|
+
return mailRunner(spec);
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Webhook execution logic — separated from feature.ts so tests can stub
|
|
2
|
+
// the fetch without touching the MSP wiring.
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
export const webhookSpecSchema = z.object({
|
|
7
|
+
url: z.string(),
|
|
8
|
+
method: z.enum(["POST", "PUT", "PATCH"]),
|
|
9
|
+
headers: z.record(z.string(), z.string()),
|
|
10
|
+
body: z.unknown().optional(),
|
|
11
|
+
auth: z
|
|
12
|
+
.union([
|
|
13
|
+
z.object({ kind: z.literal("bearer"), secretRef: z.string() }),
|
|
14
|
+
z.object({ kind: z.literal("header"), name: z.string(), secretRef: z.string() }),
|
|
15
|
+
])
|
|
16
|
+
.optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
|
|
20
|
+
|
|
21
|
+
export type WebhookDispatchResult =
|
|
22
|
+
| { readonly ok: true; readonly status: number }
|
|
23
|
+
| { readonly ok: false; readonly error: string };
|
|
24
|
+
|
|
25
|
+
// Resolves a secretRef via the test-injectable secret-store. Default
|
|
26
|
+
// implementation reads from process.env at the prefix WEBHOOK_SECRET_.
|
|
27
|
+
// Tests pass a custom resolver via setWebhookSecretResolver.
|
|
28
|
+
let secretResolver: (ref: string) => string | undefined = (ref) =>
|
|
29
|
+
process.env[`WEBHOOK_SECRET_${ref}`];
|
|
30
|
+
|
|
31
|
+
export function setWebhookSecretResolver(fn: (ref: string) => string | undefined): void {
|
|
32
|
+
secretResolver = fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
36
|
+
|
|
37
|
+
export function setWebhookFetch(fn: typeof fetch): void {
|
|
38
|
+
fetchImpl = fn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
|
|
42
|
+
const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
|
|
43
|
+
if (spec.auth) {
|
|
44
|
+
const secret = secretResolver(spec.auth.secretRef);
|
|
45
|
+
if (!secret) {
|
|
46
|
+
return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
|
|
47
|
+
}
|
|
48
|
+
if (spec.auth.kind === "bearer") {
|
|
49
|
+
headers["authorization"] = `Bearer ${secret}`;
|
|
50
|
+
} else {
|
|
51
|
+
headers[spec.auth.name] = secret;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetchImpl(spec.url, {
|
|
56
|
+
method: spec.method,
|
|
57
|
+
headers,
|
|
58
|
+
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, status: res.status };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -66,7 +66,7 @@ export function createMollieCheckoutSession(
|
|
|
66
66
|
tenantId: options.tenantId,
|
|
67
67
|
priceId: options.priceId,
|
|
68
68
|
},
|
|
69
|
-
}) as Promise<Payment>)) satisfies Payment;
|
|
69
|
+
}) as Promise<Payment>)) satisfies Payment; // @cast-boundary engine-bridge
|
|
70
70
|
|
|
71
71
|
const checkoutHref = payment.getCheckoutUrl();
|
|
72
72
|
if (!checkoutHref) {
|
|
@@ -102,7 +102,7 @@ export function verifyAndParseMollieWebhook(
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const metadata = (subscription.metadata as Record<string, string> | null) ?? {};
|
|
105
|
+
const metadata = (subscription.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
|
|
106
106
|
const tenantId = metadata["tenantId"];
|
|
107
107
|
if (!tenantId || tenantId.length === 0) return null;
|
|
108
108
|
const priceId = metadata["priceId"];
|
|
@@ -153,7 +153,7 @@ async function ensureSubscriptionForMandate(
|
|
|
153
153
|
): Promise<MollieSubscription | null> {
|
|
154
154
|
const customerId = payment.customerId;
|
|
155
155
|
if (!customerId) return null;
|
|
156
|
-
const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {};
|
|
156
|
+
const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
|
|
157
157
|
const tenantId = paymentMetadata["tenantId"];
|
|
158
158
|
const priceId = paymentMetadata["priceId"];
|
|
159
159
|
if (!tenantId || !priceId) return null;
|
|
@@ -163,7 +163,7 @@ async function ensureSubscriptionForMandate(
|
|
|
163
163
|
const existing = await client.customerSubscriptions.list(customerId);
|
|
164
164
|
const matchingExisting = existing.find(
|
|
165
165
|
(sub) =>
|
|
166
|
-
(sub.metadata as Record<string, string> | null)?.["priceId"] === priceId &&
|
|
166
|
+
(sub.metadata as Record<string, string> | null)?.["priceId"] === priceId && // @cast-boundary engine-bridge
|
|
167
167
|
(sub.status === "active" || sub.status === "pending"),
|
|
168
168
|
);
|
|
169
169
|
if (matchingExisting) return matchingExisting;
|
|
@@ -185,8 +185,12 @@ export function extractMollieId(rawBody: string, headers: Record<string, string>
|
|
|
185
185
|
const contentType = headers["content-type"] ?? "";
|
|
186
186
|
if (contentType.includes("application/json")) {
|
|
187
187
|
try {
|
|
188
|
-
const parsed = JSON.parse(rawBody)
|
|
189
|
-
|
|
188
|
+
const parsed: unknown = JSON.parse(rawBody);
|
|
189
|
+
const id =
|
|
190
|
+
typeof parsed === "object" && parsed !== null && "id" in parsed
|
|
191
|
+
? (parsed as Record<string, unknown>)["id"] // @cast-boundary engine-payload
|
|
192
|
+
: undefined;
|
|
193
|
+
return typeof id === "string" ? id : null;
|
|
190
194
|
} catch {
|
|
191
195
|
return null;
|
|
192
196
|
}
|
|
@@ -203,15 +203,15 @@ async function extractSubscriptionFromEvent(
|
|
|
203
203
|
case StripeEventTypes.customerSubscriptionCreated:
|
|
204
204
|
case StripeEventTypes.customerSubscriptionUpdated:
|
|
205
205
|
case StripeEventTypes.customerSubscriptionDeleted:
|
|
206
|
-
return event.data.object as Stripe.Subscription;
|
|
206
|
+
return event.data.object as Stripe.Subscription; // @cast-boundary engine-bridge
|
|
207
207
|
case StripeEventTypes.invoicePaid:
|
|
208
208
|
case StripeEventTypes.invoicePaymentFailed: {
|
|
209
209
|
// Lazy-fetch der subscription. invoice.subscription ist eine
|
|
210
210
|
// string-id (Stripe-Webhooks expanden nicht auto). Wir holen das
|
|
211
211
|
// full subscription-Object damit der downstream-mapping
|
|
212
212
|
// (status, tier via priceId, period-end) konsistent funktioniert.
|
|
213
|
-
const invoice = event.data.object as Stripe.Invoice;
|
|
214
|
-
const subRef = (invoice as { subscription?: string | Stripe.Subscription | null })
|
|
213
|
+
const invoice = event.data.object as Stripe.Invoice; // @cast-boundary engine-bridge
|
|
214
|
+
const subRef = (invoice as { subscription?: string | Stripe.Subscription | null }) // @cast-boundary engine-payload
|
|
215
215
|
.subscription;
|
|
216
216
|
if (!subRef) {
|
|
217
217
|
// Invoice ohne subscription-reference (= one-shot-invoice, nicht
|
|
@@ -14,6 +14,6 @@ export const activeTenantIdsQuery = defineQueryHandler({
|
|
|
14
14
|
.from(tenantTable)
|
|
15
15
|
.where(eq(tenantTable["isEnabled"], true));
|
|
16
16
|
|
|
17
|
-
return rows.map((row) => (row as DbRow)["id"] as number);
|
|
17
|
+
return rows.map((row) => (row as DbRow)["id"] as number); // @cast-boundary db-row
|
|
18
18
|
},
|
|
19
19
|
});
|
|
@@ -61,7 +61,7 @@ export const cancelInvitationWrite = defineWriteHandler({
|
|
|
61
61
|
const updateResult = await executor.update(
|
|
62
62
|
{
|
|
63
63
|
id: event.payload.invitationId,
|
|
64
|
-
version: invitation["version"] as number,
|
|
64
|
+
version: invitation["version"] as number, // @cast-boundary db-row
|
|
65
65
|
changes: { status: INVITATION_STATUS.cancelled },
|
|
66
66
|
},
|
|
67
67
|
event.user,
|
|
@@ -27,7 +27,7 @@ export const resolveUserIdsQuery = defineQueryHandler({
|
|
|
27
27
|
.select({ userId: tenantMembershipsTable.userId })
|
|
28
28
|
.from(tenantMembershipsTable)
|
|
29
29
|
.where(eq(tenantMembershipsTable.tenantId, tenantId));
|
|
30
|
-
return rows.map((r) => r["userId"] as number);
|
|
30
|
+
return rows.map((r) => r["userId"] as number); // @cast-boundary db-row
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if (userId !== undefined) {
|
|
@@ -39,11 +39,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
39
39
|
// between this read and append) surfaces as version_conflict rather than
|
|
40
40
|
// silent overwrite. Per-membership parallelism is rare; if it happens,
|
|
41
41
|
// the client retries on the error.
|
|
42
|
-
const row = existing as DbRow;
|
|
42
|
+
const row = existing as DbRow; // @cast-boundary generic-record
|
|
43
43
|
const result = await executor.update(
|
|
44
44
|
{
|
|
45
|
-
id: row["id"] as string,
|
|
46
|
-
version: row["version"] as number,
|
|
45
|
+
id: row["id"] as string, // @cast-boundary db-row
|
|
46
|
+
version: row["version"] as number, // @cast-boundary db-row
|
|
47
47
|
changes: { roles: JSON.stringify(event.payload.roles) },
|
|
48
48
|
},
|
|
49
49
|
event.user,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @runtime client
|
|
1
2
|
// Feature name
|
|
2
3
|
export const TEXT_CONTENT_FEATURE = "text-content" as const;
|
|
3
4
|
|
|
@@ -9,6 +10,7 @@ export const TextContentHandlers = {
|
|
|
9
10
|
// Qualified query handler names (QN format: scope:type:name)
|
|
10
11
|
export const TextContentQueries = {
|
|
11
12
|
bySlug: "text-content:query:by-slug",
|
|
13
|
+
byTenant: "text-content:query:by-tenant",
|
|
12
14
|
} as const;
|
|
13
15
|
|
|
14
16
|
// Error codes
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { defineFeature
|
|
1
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { bySlugQuery } from "./handlers/by-slug.query";
|
|
3
|
+
import { byTenantQuery } from "./handlers/by-tenant.query";
|
|
3
4
|
import { setWrite } from "./handlers/set.write";
|
|
4
5
|
import { textBlockEntity } from "./table";
|
|
5
6
|
|
|
@@ -11,8 +12,16 @@ import { textBlockEntity } from "./table";
|
|
|
11
12
|
//
|
|
12
13
|
// Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
|
|
13
14
|
// das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
|
|
14
|
-
// query — Routes/Render kommen pro Use-Case
|
|
15
|
-
|
|
15
|
+
// query + by-tenant-list-query — Routes/Render kommen pro Use-Case
|
|
16
|
+
// (legal-pages, Visual-Tree, etc.).
|
|
17
|
+
//
|
|
18
|
+
// **Visual-Tree-Integration (V.1.2)**: r.treeActions deklariert die
|
|
19
|
+
// Edit-Actions für Cross-Feature-Linking via buildTarget. Der Handle
|
|
20
|
+
// wird via setup-export propagiert (Memory `[EventDef-Exports-Pattern]`),
|
|
21
|
+
// sodass andere Features compile-time-typed Cross-Feature-Edits triggern
|
|
22
|
+
// können — siehe legal-pages's TreeProvider der text-content:edit als
|
|
23
|
+
// Target nutzt. Der Client-side TreeProvider lebt in `web/client-plugin.ts`.
|
|
24
|
+
export function createTextContentFeature() {
|
|
16
25
|
return defineFeature("text-content", (r) => {
|
|
17
26
|
r.entity("text-block", textBlockEntity);
|
|
18
27
|
|
|
@@ -22,8 +31,15 @@ export function createTextContentFeature(): FeatureDefinition {
|
|
|
22
31
|
|
|
23
32
|
const queries = {
|
|
24
33
|
bySlug: r.queryHandler(bySlugQuery),
|
|
34
|
+
byTenant: r.queryHandler(byTenantQuery),
|
|
25
35
|
};
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
const treeHandle = r.treeActions({
|
|
38
|
+
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
39
|
+
list: {},
|
|
40
|
+
create: { args: { folder: "" as string } },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return { handlers, queries, treeHandle };
|
|
28
44
|
});
|
|
29
45
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { type TextBlockRow, textBlocksTable } from "../table";
|
|
7
|
+
|
|
8
|
+
// Public-Read aller Text-Blocks für einen Tenant. Use-Case: Visual-Tree-
|
|
9
|
+
// Provider lädt die Slug-Liste zur Sidebar-Render. Anonymous: explizit
|
|
10
|
+
// in roles damit no-JWT-Visitors auch lesen können (Marketing-Sidebar
|
|
11
|
+
// auf Public-Pages). Tenant-Scope kommt aus query.user.tenantId; optional
|
|
12
|
+
// `tenantIdOverride` (SystemAdmin-only) — symmetrisch zu by-slug.query.
|
|
13
|
+
//
|
|
14
|
+
// **Listing statt single-row**: anders als by-slug returnt das hier
|
|
15
|
+
// `{ blocks: [...] }` mit allen Slugs des Tenants. Pro Slug nur die
|
|
16
|
+
// Summary-Felder (kein full body — den lädt der Editor on-demand via
|
|
17
|
+
// by-slug). Hält die Sidebar-Payload klein bei vielen Slugs.
|
|
18
|
+
export type TextBlockSummary = {
|
|
19
|
+
readonly slug: string;
|
|
20
|
+
readonly lang: string;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly body: string | null;
|
|
23
|
+
readonly updatedAt: Date;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const byTenantQuery = defineQueryHandler({
|
|
27
|
+
name: "by-tenant",
|
|
28
|
+
schema: z.object({
|
|
29
|
+
/** Optional cross-tenant read — nur für SystemAdmin. Symmetrisch
|
|
30
|
+
* zur by-slug.query und set.write Override-Logik. */
|
|
31
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
32
|
+
}),
|
|
33
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
34
|
+
handler: async (query, ctx) => {
|
|
35
|
+
const override = query.payload.tenantIdOverride;
|
|
36
|
+
if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
|
|
37
|
+
throw new AccessDeniedError({
|
|
38
|
+
i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
39
|
+
details: { reason: "tenant_override_requires_system_admin" },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const tenantId = override ?? query.user.tenantId;
|
|
43
|
+
const rows = castTenantRows<TextBlockRow>(
|
|
44
|
+
await ctx.db.select().from(textBlocksTable).where(eq(textBlocksTable["tenantId"], tenantId)),
|
|
45
|
+
);
|
|
46
|
+
return {
|
|
47
|
+
blocks: rows.map((row) => ({
|
|
48
|
+
slug: row.slug,
|
|
49
|
+
lang: row.lang,
|
|
50
|
+
title: row.title,
|
|
51
|
+
body: row.body,
|
|
52
|
+
updatedAt: row.updatedAt,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -68,7 +68,7 @@ export const setWrite = defineWriteHandler({
|
|
|
68
68
|
// Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
|
|
69
69
|
// SYSTEM_TENANT) als by verwendet.
|
|
70
70
|
const executorUser =
|
|
71
|
-
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
|
|
71
|
+
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
|
|
72
72
|
|
|
73
73
|
const existing = await fetchOne<TextBlockRow>(
|
|
74
74
|
db,
|