@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,57 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
|
|
6
|
+
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "../resolver";
|
|
7
|
+
import { tenantRetentionOverrideTable } from "../schema/tenant-retention-override";
|
|
8
|
+
|
|
9
|
+
// retention:query:policy-for — Cross-Feature-API fuer den Forget-Flow.
|
|
10
|
+
//
|
|
11
|
+
// user-data-rights-Sprint-2.U5 ruft das pro Entity um zu wissen ob ein
|
|
12
|
+
// Forget mit "delete" oder "anonymize" oder "blockDelete-bis-Frist"
|
|
13
|
+
// laufen soll. Plus Cleanup-Job-Sprint-2.D2b fuer das gleiche.
|
|
14
|
+
//
|
|
15
|
+
// Tenant-Preset-Storage existiert noch nicht (kommt mit S2.D2b ueber
|
|
16
|
+
// einen tenant-config-key). Bis dahin tenantPreset=null — der Resolver
|
|
17
|
+
// liefert dann nur die Entity-Default-Layer + Override.
|
|
18
|
+
//
|
|
19
|
+
// access: openToAll — andere Features im selben Tenant duerfen das
|
|
20
|
+
// abrufen. Keine PII im Result, nur Policy-Metadata.
|
|
21
|
+
export const policyForQuery = defineQueryHandler({
|
|
22
|
+
name: "policy-for",
|
|
23
|
+
schema: z.object({
|
|
24
|
+
entityName: z.string().min(1).max(100),
|
|
25
|
+
}),
|
|
26
|
+
access: { openToAll: true },
|
|
27
|
+
handler: async (query, ctx): Promise<EffectiveRetentionPolicy> => {
|
|
28
|
+
const entityName = query.payload.entityName;
|
|
29
|
+
|
|
30
|
+
// Layer 3: Tenant-Override aus DB laden (UNIQUE(tenantId, entityName))
|
|
31
|
+
const overrideRow = (await fetchOne(
|
|
32
|
+
ctx.db,
|
|
33
|
+
tenantRetentionOverrideTable,
|
|
34
|
+
eq(tenantRetentionOverrideTable["tenantId"], query.user.tenantId),
|
|
35
|
+
eq(tenantRetentionOverrideTable["entityName"], entityName),
|
|
36
|
+
)) as { config: string | null } | null; // @cast-boundary db-runner
|
|
37
|
+
|
|
38
|
+
const tenantOverride = parseRetentionOverrideOrNull(
|
|
39
|
+
overrideRow?.config ?? null,
|
|
40
|
+
query.user.tenantId,
|
|
41
|
+
"data-retention:policy-for",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Layer 1: Entity-Default aus Registry
|
|
45
|
+
const entityDef = ctx.registry?.getEntity(entityName) ?? null;
|
|
46
|
+
|
|
47
|
+
// Layer 2: Tenant-Preset — Storage kommt mit S2.D2b. Bis dahin null.
|
|
48
|
+
const tenantPreset = null;
|
|
49
|
+
|
|
50
|
+
return resolveRetentionPolicy({
|
|
51
|
+
entityName,
|
|
52
|
+
entityDef,
|
|
53
|
+
tenantPreset,
|
|
54
|
+
tenantOverride,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
EffectiveRetentionPolicy,
|
|
3
|
+
ResolveForTenantArgs,
|
|
4
|
+
ResolveRetentionPolicyArgs,
|
|
5
|
+
RetentionOverride,
|
|
6
|
+
RetentionPreset,
|
|
7
|
+
RetentionPresetKey,
|
|
8
|
+
} from "./feature";
|
|
9
|
+
export {
|
|
10
|
+
createDataRetentionFeature,
|
|
11
|
+
RETENTION_PRESETS,
|
|
12
|
+
resolveRetentionPolicy,
|
|
13
|
+
resolveRetentionPolicyForTenant,
|
|
14
|
+
retentionOverrideSchema,
|
|
15
|
+
SELECTABLE_RETENTION_PRESETS,
|
|
16
|
+
tenantRetentionOverrideEntity,
|
|
17
|
+
tenantRetentionOverrideTable,
|
|
18
|
+
} from "./feature";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// keepFor-Format-Parser: "30d" / "10y" / "6m" / "1w" / "24h" → Cutoff-Instant.
|
|
2
|
+
//
|
|
3
|
+
// Nicht millisekunden-praezise — DSGVO-Aufbewahrungspflichten haben
|
|
4
|
+
// Tag-/Monat-Granularitaet. "6m" = 6×30d, "10y" = 10×365d. Cleanup-Job
|
|
5
|
+
// laeuft taeglich, ein paar Tage Differenz beim Cutoff sind akzeptabel.
|
|
6
|
+
//
|
|
7
|
+
// Boot-Validator (S0.2) hat das Format schon gegen /^\d+[hdwmy]$/
|
|
8
|
+
// gecheckt — hier defensiv nochmal validiert fuer Migration-Edge-Cases.
|
|
9
|
+
//
|
|
10
|
+
// Temporal kommt via globalThis (Polyfill in framework-Boot installiert).
|
|
11
|
+
// `getTemporal()` aus framework/time gibt typed Zugriff.
|
|
12
|
+
|
|
13
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
14
|
+
|
|
15
|
+
const KEEP_FOR_PATTERN = /^(\d+)([hdwmy])$/;
|
|
16
|
+
|
|
17
|
+
const UNIT_TO_DAYS: Readonly<Record<string, number>> = {
|
|
18
|
+
d: 1,
|
|
19
|
+
w: 7,
|
|
20
|
+
m: 30,
|
|
21
|
+
y: 365,
|
|
22
|
+
} satisfies Readonly<Record<string, number>>;
|
|
23
|
+
|
|
24
|
+
export class InvalidKeepForError extends Error {
|
|
25
|
+
constructor(spec: string) {
|
|
26
|
+
super(
|
|
27
|
+
`Invalid keepFor format "${spec}" — expected /^\\d+[hdwmy]$/ (e.g. "30d", "10y", "6m", "1w", "24h")`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Re-export von Temporal.Instant als Type-Alias damit Caller den Type
|
|
33
|
+
// nicht selbst aus globalThis ziehen muessen.
|
|
34
|
+
export type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Berechnet den Cutoff-Instant — Rows mit reference < cutoff sind
|
|
38
|
+
* abgelaufen und werden vom Cleanup-Job geraeumt.
|
|
39
|
+
*
|
|
40
|
+
* @param spec keepFor-String wie "30d", "10y", "6m", "1w", "24h"
|
|
41
|
+
* @param now Aktueller Zeitpunkt (advisor-Pattern: injection-Parameter
|
|
42
|
+
* fuer Time-Travel-Tests, kein global Temporal.Now)
|
|
43
|
+
*/
|
|
44
|
+
export function computeCutoff(spec: string, now: Instant): Instant {
|
|
45
|
+
const match = KEEP_FOR_PATTERN.exec(spec);
|
|
46
|
+
if (!match) {
|
|
47
|
+
throw new InvalidKeepForError(spec);
|
|
48
|
+
}
|
|
49
|
+
const amount = Number.parseInt(match[1] ?? "0", 10);
|
|
50
|
+
const unit = match[2] ?? "";
|
|
51
|
+
|
|
52
|
+
if (unit === "h") {
|
|
53
|
+
return now.subtract({ hours: amount });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const daysFactor = UNIT_TO_DAYS[unit];
|
|
57
|
+
if (daysFactor === undefined) {
|
|
58
|
+
throw new InvalidKeepForError(spec);
|
|
59
|
+
}
|
|
60
|
+
return now.subtract({ hours: amount * daysFactor * 24 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ist referenceTimestamp aelter als der keepFor-Cutoff bei now?
|
|
65
|
+
* Cleanup-Job nutzt das pro Row.
|
|
66
|
+
*/
|
|
67
|
+
export function isPastCutoff(args: {
|
|
68
|
+
readonly referenceTimestamp: Instant;
|
|
69
|
+
readonly keepFor: string;
|
|
70
|
+
readonly now: Instant;
|
|
71
|
+
}): boolean {
|
|
72
|
+
const T = getTemporal();
|
|
73
|
+
const cutoff = computeCutoff(args.keepFor, args.now);
|
|
74
|
+
return T.Instant.compare(args.referenceTimestamp, cutoff) < 0;
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Zod-Schema fuer RetentionOverride mit .strict() — Sprint 2.D2.5 (M2+M3).
|
|
2
|
+
//
|
|
3
|
+
// Symmetrisch zu compliance-profiles override-schema.ts (S1.9 Z3).
|
|
4
|
+
// Sub-Sprint-Pattern (advisor-pinned): bei jedem User-konfigurierbarem
|
|
5
|
+
// JSON-Override → strict-Zod + enum-validated.
|
|
6
|
+
//
|
|
7
|
+
// Schuetzt vor:
|
|
8
|
+
// - Top-Level-Tippfehler ("keepfor" statt "keepFor") — strict()-Reject
|
|
9
|
+
// - Strategy-Enum-Drift ("delete" statt "hardDelete") — z.enum-Reject
|
|
10
|
+
// - keepFor-Format-Drift ("30days" statt "30d") — regex-Reject
|
|
11
|
+
//
|
|
12
|
+
// Tenant-Override darf alle drei Properties weglassen (Resolver
|
|
13
|
+
// fallback auf Preset/Entity-Default), aber WAS gesetzt ist muss
|
|
14
|
+
// gueltig sein.
|
|
15
|
+
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
19
|
+
|
|
20
|
+
const retentionStrategySchema = z.enum(["hardDelete", "softDelete", "anonymize", "blockDelete"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* RetentionOverride-Zod-Schema mit .strict() — fuer (a) set-override-
|
|
24
|
+
* Handler-Validation (S2.D3) und (b) DB-Loader im Cleanup-Job (S2.D2b)
|
|
25
|
+
* der invalides JSON aus der config-Spalte loggt + skipt statt mit
|
|
26
|
+
* undefined behavior weiterzumachen.
|
|
27
|
+
*/
|
|
28
|
+
export const retentionOverrideSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
keepFor: z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(KEEP_FOR_PATTERN, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y', '6m')")
|
|
33
|
+
.optional(),
|
|
34
|
+
strategy: retentionStrategySchema.optional(),
|
|
35
|
+
reference: z.string().min(1).optional(),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Retention-Presets — vorgefertigte Bündel von per-Entity-Aufbewahrungs-
|
|
2
|
+
// Konfigurationen pro Compliance-Regime. Tenant-Admin wählt EINES (analog
|
|
3
|
+
// zu compliance-profiles); Entity-Default + Tenant-Override liegen als
|
|
4
|
+
// Schichten 1+3 darüber (siehe resolver.ts).
|
|
5
|
+
//
|
|
6
|
+
// Plan-Roadmap docs/plans/datenschutz/core-data-retention.md hat 8 Presets
|
|
7
|
+
// vorgeschlagen. Sprint-2-MVP: 3 Production-Presets + 1 dev-Default,
|
|
8
|
+
// rest folgt on-demand wenn Customer fragt.
|
|
9
|
+
//
|
|
10
|
+
// Presets sind pure Daten — keine Logik. Erweitern = Constant erweitern,
|
|
11
|
+
// kein Code-Eingriff.
|
|
12
|
+
|
|
13
|
+
import type { RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pro Entity-Name gemappte Retention-Policy. Entity-Namen sind der
|
|
17
|
+
* String aus r.entity("name", ...) — kebab-case oder lowercase.
|
|
18
|
+
* Cleanup-Job iteriert alle bekannten Entities; was nicht im Preset
|
|
19
|
+
* steht, fällt auf Entity-Default (Layer 1) zurück.
|
|
20
|
+
*/
|
|
21
|
+
export type RetentionPreset = Readonly<Record<string, RetentionDef>>;
|
|
22
|
+
|
|
23
|
+
export type RetentionPresetKey = "default" | "dsgvo-basic" | "dsgvo-hgb" | "swiss-dsg";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MVP-Set für Sprint 2. Erweiterungen (hipaa, ccpa, aggressive-gdpr,
|
|
27
|
+
* pipeda-default, ca-quebec-l25) kommen on-demand.
|
|
28
|
+
*/
|
|
29
|
+
export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPreset>> = {
|
|
30
|
+
// Default — keine Auto-Aktion. Nur sinnvoll für Dev/Staging.
|
|
31
|
+
// Production-Tenant der "default" stehen lässt → Cleanup-Job
|
|
32
|
+
// schreibt Audit-Eintrag "skipped: no preset selected".
|
|
33
|
+
default: {},
|
|
34
|
+
|
|
35
|
+
// DSGVO Basic — Datenminimierung ohne Buchhaltungspflichten.
|
|
36
|
+
"dsgvo-basic": {
|
|
37
|
+
auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
38
|
+
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
39
|
+
httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// DSGVO + HGB — deutsche Aufbewahrungspflichten überlagert. Order
|
|
43
|
+
// wird anonymisiert (PII raus, Geschäftsdaten bleiben), Invoice +
|
|
44
|
+
// Booking sind blockDelete bis 10 Jahre, dann Anonymize.
|
|
45
|
+
"dsgvo-hgb": {
|
|
46
|
+
auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
47
|
+
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
48
|
+
httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
|
|
49
|
+
invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
50
|
+
booking: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
51
|
+
contract: { keepFor: "6y", strategy: "blockDelete", reference: "createdAt" },
|
|
52
|
+
order: { keepFor: "6y", strategy: "anonymize", reference: "completedAt" },
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Schweizer DSG — ähnlich DSGVO mit OR Art. 958f Aufbewahrung.
|
|
56
|
+
"swiss-dsg": {
|
|
57
|
+
auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
58
|
+
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
59
|
+
invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
60
|
+
},
|
|
61
|
+
} satisfies Readonly<Record<RetentionPresetKey, RetentionPreset>>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Auswählbare Presets für den Onboarding-Banner. "default" ist Migration-
|
|
65
|
+
* Edge-Case und wird nicht angezeigt — Production-Tenants wählen ein
|
|
66
|
+
* echtes Preset.
|
|
67
|
+
*/
|
|
68
|
+
export const SELECTABLE_RETENTION_PRESETS: readonly RetentionPresetKey[] = [
|
|
69
|
+
"dsgvo-basic",
|
|
70
|
+
"dsgvo-hgb",
|
|
71
|
+
"swiss-dsg",
|
|
72
|
+
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Direkter Resolver-Helper fuer Bulk-Iteration (S2.U5b Cleanup-Runner).
|
|
2
|
+
//
|
|
3
|
+
// `policy-for` Query (handlers/policy-for.query.ts) ist die Cross-Feature-
|
|
4
|
+
// API fuer einzelne Lookups. Der Cleanup-Runner iteriert N Entities × M
|
|
5
|
+
// Tenants — Handler-Roundtrip pro Lookup waere zu teuer + braucht einen
|
|
6
|
+
// HandlerContext. Beide Pfade nutzen denselben `parseRetentionOverrideOrNull`
|
|
7
|
+
// + `resolveRetentionPolicy`, also kein Drift-Risiko.
|
|
8
|
+
|
|
9
|
+
import { type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { eq } from "drizzle-orm";
|
|
12
|
+
import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
|
|
13
|
+
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
|
|
14
|
+
import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
|
|
15
|
+
|
|
16
|
+
export interface ResolveForTenantArgs {
|
|
17
|
+
readonly db: DbRunner;
|
|
18
|
+
readonly registry: Registry;
|
|
19
|
+
readonly tenantId: TenantId;
|
|
20
|
+
readonly entityName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolveRetentionPolicyForTenant(
|
|
24
|
+
args: ResolveForTenantArgs,
|
|
25
|
+
): Promise<EffectiveRetentionPolicy> {
|
|
26
|
+
const overrideRow = (await fetchOne(
|
|
27
|
+
args.db,
|
|
28
|
+
tenantRetentionOverrideTable,
|
|
29
|
+
eq(tenantRetentionOverrideTable["tenantId"], args.tenantId),
|
|
30
|
+
eq(tenantRetentionOverrideTable["entityName"], args.entityName),
|
|
31
|
+
)) as { config: string | null } | null; // @cast-boundary db-runner
|
|
32
|
+
|
|
33
|
+
const tenantOverride = parseRetentionOverrideOrNull(
|
|
34
|
+
overrideRow?.config ?? null,
|
|
35
|
+
args.tenantId,
|
|
36
|
+
"data-retention:resolve-for-tenant",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const entityDef = args.registry.getEntity(args.entityName) ?? null;
|
|
40
|
+
|
|
41
|
+
// Layer 2 (Tenant-Preset) kommt mit S2.D2b. Bis dahin null.
|
|
42
|
+
const tenantPreset = null;
|
|
43
|
+
|
|
44
|
+
return resolveRetentionPolicy({
|
|
45
|
+
entityName: args.entityName,
|
|
46
|
+
entityDef,
|
|
47
|
+
tenantPreset,
|
|
48
|
+
tenantOverride,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// 3-Schicht-Retention-Resolver.
|
|
2
|
+
//
|
|
3
|
+
// Layer 1: Entity-Default — Feature-Author setzt r.entity({retention})
|
|
4
|
+
// Layer 2: Tenant-Preset — Tenant-Admin wählt Bundle (RETENTION_PRESETS)
|
|
5
|
+
// Layer 3: Tenant-Override — per (tenantId, entityName) JSON-Override
|
|
6
|
+
//
|
|
7
|
+
// Resolver-Reihenfolge:
|
|
8
|
+
// effective = override(tenantId, entityName)
|
|
9
|
+
// ?? preset[tenant.retentionPreset][entityName]
|
|
10
|
+
// ?? entity.retention (Code-Default aus EntityDefinition)
|
|
11
|
+
// ?? null (keine Auto-Aktion)
|
|
12
|
+
//
|
|
13
|
+
// Cleanup-Job in S2.D2 ruft das pro (tenantId, entityName) und
|
|
14
|
+
// entscheidet was zu tun ist (hardDelete / softDelete / anonymize /
|
|
15
|
+
// blockDelete-mit-Frist-Check).
|
|
16
|
+
//
|
|
17
|
+
// Cross-Feature-API r.exposesApi("retention.policyFor") (S2.D3)
|
|
18
|
+
// macht das aus user-data-rights heraus konsumierbar — Forget-Flow
|
|
19
|
+
// fragt blockDelete-Felder ab + anonymisiert sie statt zu löschen.
|
|
20
|
+
|
|
21
|
+
import type { EntityDefinition, RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { RETENTION_PRESETS, type RetentionPresetKey } from "./presets";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Roh-Override aus der DB-Tabelle (config-Spalte als JSON-String).
|
|
26
|
+
* Nicht das gleiche wie RetentionDef weil hier alles optional ist —
|
|
27
|
+
* Override darf einzelne Properties überschreiben.
|
|
28
|
+
*/
|
|
29
|
+
export interface RetentionOverride {
|
|
30
|
+
readonly keepFor?: string;
|
|
31
|
+
readonly strategy?: RetentionDef["strategy"];
|
|
32
|
+
readonly reference?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Effektive Policy nach Resolver-Lauf. Source dokumentiert WELCHE
|
|
37
|
+
* Schicht den Wert geliefert hat — Audit-Trail für DPO.
|
|
38
|
+
*
|
|
39
|
+
* `override-incomplete` bedeutet: Tenant-Override ist gesetzt, aber
|
|
40
|
+
* füllt keepFor weder selbst noch via Preset-/Entity-Fallback.
|
|
41
|
+
* Cleanup-Job logt eine Warning + skippt — anstatt mit Default-"0d"
|
|
42
|
+
* sofort alles zu löschen.
|
|
43
|
+
*/
|
|
44
|
+
export interface EffectiveRetentionPolicy {
|
|
45
|
+
readonly entityName: string;
|
|
46
|
+
readonly policy: RetentionDef | null;
|
|
47
|
+
readonly source: "override" | "preset" | "entity-default" | "none" | "override-incomplete";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResolveRetentionPolicyArgs {
|
|
51
|
+
readonly entityName: string;
|
|
52
|
+
readonly entityDef: EntityDefinition | null;
|
|
53
|
+
readonly tenantPreset: RetentionPresetKey | null;
|
|
54
|
+
readonly tenantOverride: RetentionOverride | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Auswertung der drei Schichten. Pure Function — kein DB-Access, alle
|
|
59
|
+
* Inputs werden vom Caller besorgt (Cleanup-Job aggregiert pro Tenant
|
|
60
|
+
* vorher).
|
|
61
|
+
*/
|
|
62
|
+
export function resolveRetentionPolicy(args: ResolveRetentionPolicyArgs): EffectiveRetentionPolicy {
|
|
63
|
+
const { entityName, entityDef, tenantPreset, tenantOverride } = args;
|
|
64
|
+
|
|
65
|
+
// Layer 3: Override wins, aber Override darf Felder weglassen — dann
|
|
66
|
+
// fallen die einzelnen Properties auf Layer 2/1 zurück.
|
|
67
|
+
if (tenantOverride !== null) {
|
|
68
|
+
const baseFromPreset =
|
|
69
|
+
tenantPreset !== null ? (RETENTION_PRESETS[tenantPreset]?.[entityName] ?? null) : null;
|
|
70
|
+
const baseFromEntity = entityDef?.retention ?? null;
|
|
71
|
+
const base = baseFromPreset ?? baseFromEntity;
|
|
72
|
+
|
|
73
|
+
const keepFor = tenantOverride.keepFor ?? base?.keepFor;
|
|
74
|
+
const strategy = tenantOverride.strategy ?? base?.strategy;
|
|
75
|
+
|
|
76
|
+
// keepFor + strategy sind Pflicht für jede aktive Policy. Wenn
|
|
77
|
+
// weder Override noch Base sie liefert, ist das Override semantisch
|
|
78
|
+
// unvollständig — Cleanup-Job soll WARNEN statt mit Default-"0d"
|
|
79
|
+
// sofort alles löschen. Source-Marker dokumentiert das.
|
|
80
|
+
if (keepFor === undefined || strategy === undefined) {
|
|
81
|
+
return { entityName, policy: null, source: "override-incomplete" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const merged: RetentionDef = {
|
|
85
|
+
keepFor,
|
|
86
|
+
strategy,
|
|
87
|
+
reference: tenantOverride.reference ?? base?.reference,
|
|
88
|
+
};
|
|
89
|
+
return { entityName, policy: merged, source: "override" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Layer 2: Preset
|
|
93
|
+
if (tenantPreset !== null) {
|
|
94
|
+
const fromPreset = RETENTION_PRESETS[tenantPreset]?.[entityName];
|
|
95
|
+
if (fromPreset) {
|
|
96
|
+
return { entityName, policy: fromPreset, source: "preset" };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Layer 1: Entity-Default
|
|
101
|
+
if (entityDef?.retention) {
|
|
102
|
+
return { entityName, policy: entityDef.retention, source: "entity-default" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Nichts da — Cleanup-Job überspringt diese Entity für diesen Tenant.
|
|
106
|
+
return { entityName, policy: null, source: "none" };
|
|
107
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createLongTextField,
|
|
5
|
+
createTextField,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// tenantRetentionOverride — Layer 3 im 3-Schicht-Resolver.
|
|
9
|
+
//
|
|
10
|
+
// Per (tenantId, entityName) eine optionale Override-Config die das
|
|
11
|
+
// Preset (Layer 2) für genau diese eine Entity überschreibt. Use-Cases:
|
|
12
|
+
// - Anwaltskanzlei in DE: caseFile 6y blockDelete (nicht im Preset)
|
|
13
|
+
// - Pilot-Tenant der länger speichert für Test
|
|
14
|
+
// - Branchenspezifische verkürzte Fristen
|
|
15
|
+
//
|
|
16
|
+
// reason ist Pflicht — Audit für DPO + Aufsichtsbehörde nachvollziehbar.
|
|
17
|
+
//
|
|
18
|
+
// config ist JSON-String mit `{ keepFor, strategy, reference? }`. Zod-
|
|
19
|
+
// Schema validiert beim set-override-Call (S2.D2 ggf. erweitert).
|
|
20
|
+
//
|
|
21
|
+
// Tenant-1-zu-N: pro Tenant beliebig viele Entity-Overrides. UNIQUE-
|
|
22
|
+
// Index auf (tenantId, entityName) damit pro Entity max ein Override.
|
|
23
|
+
export const tenantRetentionOverrideEntity = createEntity({
|
|
24
|
+
table: "read_tenant_retention_overrides",
|
|
25
|
+
fields: {
|
|
26
|
+
entityName: createTextField({
|
|
27
|
+
required: true,
|
|
28
|
+
maxLength: 100,
|
|
29
|
+
allowPlaintext: "is-business-data",
|
|
30
|
+
}),
|
|
31
|
+
config: createLongTextField({
|
|
32
|
+
required: true,
|
|
33
|
+
allowPlaintext: "is-business-data",
|
|
34
|
+
}),
|
|
35
|
+
reason: createTextField({
|
|
36
|
+
required: true,
|
|
37
|
+
maxLength: 500,
|
|
38
|
+
allowPlaintext: "is-business-data",
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
indexes: [{ unique: true, columns: ["tenantId", "entityName"] }],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const tenantRetentionOverrideTable = buildDrizzleTable(
|
|
45
|
+
"tenantRetentionOverride",
|
|
46
|
+
tenantRetentionOverrideEntity,
|
|
47
|
+
);
|
package/src/delivery/feature.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
31
31
|
table: deliveryAttemptsTable,
|
|
32
32
|
apply: {
|
|
33
33
|
[DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
|
|
34
|
-
const p = event.payload as z.infer<typeof deliveryAttemptSchema>;
|
|
34
|
+
const p = event.payload as z.infer<typeof deliveryAttemptSchema>; // @cast-boundary engine-payload
|
|
35
35
|
// PK = aggregateId — replaying the same event twice conflicts on
|
|
36
36
|
// the PK rather than silently duplicating the log row.
|
|
37
37
|
await tx.insert(deliveryAttemptsTable).values({
|
package/src/delivery/testing.ts
CHANGED
|
@@ -41,7 +41,6 @@ export function createDeliveryTestContext(
|
|
|
41
41
|
_notifyFactory:
|
|
42
42
|
(user: { id: number; tenantId: TenantId }, tenantId: TenantId) =>
|
|
43
43
|
(notificationType: string, notifyOptions: Record<string, unknown>) =>
|
|
44
|
-
// @cast-boundary engine-bridge
|
|
45
|
-
deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId),
|
|
44
|
+
deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId), // @cast-boundary engine-bridge
|
|
46
45
|
};
|
|
47
46
|
}
|
|
@@ -137,7 +137,7 @@ export async function upsertPreference(
|
|
|
137
137
|
// minor driver-version shifts without drifting wide.
|
|
138
138
|
function isUniqueViolation(err: unknown): boolean {
|
|
139
139
|
if (typeof err !== "object" || err === null) return false;
|
|
140
|
-
const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown };
|
|
140
|
+
const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown }; // @cast-boundary error-details
|
|
141
141
|
if (e.code === "23505") return true;
|
|
142
142
|
if (e.cause && typeof e.cause === "object" && e.cause.code === "23505") return true;
|
|
143
143
|
if (typeof e.message === "string" && e.message.includes("23505")) return true;
|
|
@@ -78,7 +78,7 @@ export function createFeatureTogglesFeature(options: FeatureTogglesOptions): Fea
|
|
|
78
78
|
// (validated on append). Shallow-cast to a typed shape rather
|
|
79
79
|
// than re-parsing — the payload round-trips through JSON and is
|
|
80
80
|
// fixed at the source.
|
|
81
|
-
const payload = event.payload as { featureName: string; enabled: boolean };
|
|
81
|
+
const payload = event.payload as { featureName: string; enabled: boolean }; // @cast-boundary engine-payload
|
|
82
82
|
options.getRuntime().apply(payload.featureName, payload.enabled);
|
|
83
83
|
},
|
|
84
84
|
},
|
|
@@ -12,7 +12,7 @@ export const listQuery = defineQueryHandler({
|
|
|
12
12
|
access: { roles: ["SystemAdmin", "Admin"] },
|
|
13
13
|
handler: async (_event, ctx) => {
|
|
14
14
|
type Row = typeof globalFeatureStateTable.$inferSelect;
|
|
15
|
-
const rows = (await ctx.db.select().from(globalFeatureStateTable)) as Row[];
|
|
15
|
+
const rows = (await ctx.db.select().from(globalFeatureStateTable)) as Row[]; // @cast-boundary db-row
|
|
16
16
|
return {
|
|
17
17
|
items: rows.map((r) => ({
|
|
18
18
|
featureName: r.featureName,
|
|
@@ -21,7 +21,7 @@ export const registeredQuery = defineQueryHandler({
|
|
|
21
21
|
featureName: globalFeatureStateTable.featureName,
|
|
22
22
|
enabled: globalFeatureStateTable.enabled,
|
|
23
23
|
})
|
|
24
|
-
.from(globalFeatureStateTable)) as OverrideRow[];
|
|
24
|
+
.from(globalFeatureStateTable)) as OverrideRow[]; // @cast-boundary db-row
|
|
25
25
|
const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
|
|
26
26
|
|
|
27
27
|
// SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
|
|
@@ -31,7 +31,14 @@ export const registeredQuery = defineQueryHandler({
|
|
|
31
31
|
// dokumentiert in DispatcherOptions.effectiveFeatures.
|
|
32
32
|
const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
|
|
33
33
|
|
|
34
|
-
const items
|
|
34
|
+
const items: Array<{
|
|
35
|
+
name: string;
|
|
36
|
+
toggleable: boolean;
|
|
37
|
+
default: boolean | null;
|
|
38
|
+
override: boolean | null;
|
|
39
|
+
requires: readonly string[];
|
|
40
|
+
effective: boolean | null;
|
|
41
|
+
}> = [];
|
|
35
42
|
for (const feature of ctx.registry.features.values()) {
|
|
36
43
|
const toggleable = feature.toggleableDefault !== undefined;
|
|
37
44
|
const override = overrides.get(feature.name);
|
|
@@ -71,7 +71,7 @@ export function createSetWriteHandler(getRuntime: () => GlobalFeatureToggleRunti
|
|
|
71
71
|
.select()
|
|
72
72
|
.from(globalFeatureStateTable)
|
|
73
73
|
.where(eq(globalFeatureStateTable.featureName, featureName))
|
|
74
|
-
.limit(1)) as StateRow[];
|
|
74
|
+
.limit(1)) as StateRow[]; // @cast-boundary db-row
|
|
75
75
|
|
|
76
76
|
const previousEnabled = existing?.enabled ?? null;
|
|
77
77
|
|
|
@@ -123,11 +123,11 @@ export function createSetWriteHandler(getRuntime: () => GlobalFeatureToggleRunti
|
|
|
123
123
|
// and filtering by payload.featureName is trivial at query time.
|
|
124
124
|
// This mirrors how `config` handles the same constraint for
|
|
125
125
|
// its config-changed events.
|
|
126
|
-
//
|
|
126
|
+
// unsafeAppendEvent — bundled-features ohne lokalen Wrapper. Apps
|
|
127
127
|
// mit `yarn kumiko codegen` kriegen `.kumiko/define.ts` als strict-
|
|
128
128
|
// path; bundled-features bleibt bei der unsafe-Variante. Schema-
|
|
129
129
|
// Validation läuft trotzdem via r.defineEvent("toggle-set", ...).
|
|
130
|
-
await ctx.
|
|
130
|
+
await ctx.unsafeAppendEvent({
|
|
131
131
|
aggregateId: SYSTEM_TENANT_ID,
|
|
132
132
|
aggregateType: FEATURE_TOGGLE_AGGREGATE_TYPE,
|
|
133
133
|
type: FEATURE_TOGGLE_SET_EVENT_NAME,
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
25
25
|
import {
|
|
26
26
|
access,
|
|
27
|
+
type ConfigAccessor,
|
|
27
28
|
createTenantConfig,
|
|
28
29
|
defineFeature,
|
|
29
|
-
type
|
|
30
|
+
type Registry,
|
|
30
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
32
|
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
32
33
|
|
|
@@ -36,13 +37,52 @@ const FEATURE_NAME = "file-foundation";
|
|
|
36
37
|
// Plugin-Interface — what a Provider-Plugin must implement
|
|
37
38
|
// =============================================================================
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu
|
|
42
|
+
* fett (haelt tx, actor, signal etc.) — Provider sollen sich auf die
|
|
43
|
+
* read-Felder beschraenken die fuer Tenant-Config + Secret-Lookup
|
|
44
|
+
* gebraucht werden.
|
|
45
|
+
*
|
|
46
|
+
* **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt
|
|
47
|
+
* es keinen request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider
|
|
48
|
+
* `ctx.tx` lesen wuerde, wuerde der ganze Worker-Pfad zur Runtime
|
|
49
|
+
* brechen — und das wuerde NUR mit S3 und nur in production auffallen.
|
|
50
|
+
* Die schmale Surface zwingt Provider zur expliziten Erweiterung
|
|
51
|
+
* (extra-arg) statt silent ctx-feld-ausnutzen.
|
|
52
|
+
*
|
|
53
|
+
* **Felder:**
|
|
54
|
+
* config — fuer tenant-config-reads (bucket/region/endpoint/...)
|
|
55
|
+
* registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
|
|
56
|
+
* secrets — fuer tenant-secret-reads (s3.secretAccessKey)
|
|
57
|
+
* _userId — Audit-Identity fuer secret-reads. Im Handler-Pfad setzt
|
|
58
|
+
* der dispatcher das auf die Caller-User-ID; im Worker-Pfad
|
|
59
|
+
* muss der r.job-Wrap das explizit auf eine System-Identity
|
|
60
|
+
* setzen (z.B. "system:user-data-rights:run-export-jobs").
|
|
61
|
+
*/
|
|
62
|
+
export type FileProviderContext = {
|
|
63
|
+
readonly config?: ConfigAccessor;
|
|
64
|
+
readonly registry?: Registry;
|
|
65
|
+
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
66
|
+
readonly _userId?: string | undefined;
|
|
67
|
+
};
|
|
68
|
+
|
|
39
69
|
/**
|
|
40
70
|
* File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
|
|
41
71
|
* file-provider-azure-blob, ...) registers an implementation via
|
|
42
72
|
* `r.useExtension("fileProvider", "<name>", { build })`.
|
|
73
|
+
*
|
|
74
|
+
* **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext,
|
|
75
|
+
* nicht ein voller HandlerContext. Felder ausserhalb der schmalen
|
|
76
|
+
* Surface (z.B. `ctx.tx`, `ctx.actor`, `ctx.signal`, `ctx.notify`) sind
|
|
77
|
+
* im Worker-Pfad (r.job-getriggerte Provider-Builds) NICHT vorhanden.
|
|
78
|
+
* Cast `ctx as unknown as HandlerContext` macht den Compiler happy aber
|
|
79
|
+
* fliegt zur Runtime im Worker — und der Crash kommt erst in production
|
|
80
|
+
* mit dem ersten S3-Tenant. Wenn ein Plugin Felder braucht die nicht in
|
|
81
|
+
* FileProviderContext sind: lieber FileProviderContext explizit erweitern
|
|
82
|
+
* (sichtbarer breaking change) als ctx-cast.
|
|
43
83
|
*/
|
|
44
84
|
export type FileProviderPlugin = {
|
|
45
|
-
readonly build: (ctx:
|
|
85
|
+
readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
|
|
46
86
|
};
|
|
47
87
|
|
|
48
88
|
// =============================================================================
|
|
@@ -77,7 +117,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
77
117
|
// =============================================================================
|
|
78
118
|
|
|
79
119
|
export async function createFileProviderForTenant(
|
|
80
|
-
ctx:
|
|
120
|
+
ctx: FileProviderContext,
|
|
81
121
|
tenantId: string,
|
|
82
122
|
handlerName = "file-foundation:provider-factory",
|
|
83
123
|
): Promise<FileStorageProvider> {
|
|
@@ -97,7 +137,7 @@ export async function createFileProviderForTenant(
|
|
|
97
137
|
await ctxConfig(fileFoundationFeature.exports.configKeys.provider),
|
|
98
138
|
FEATURE_NAME,
|
|
99
139
|
"provider",
|
|
100
|
-
) as string;
|
|
140
|
+
) as string; // @cast-boundary engine-payload
|
|
101
141
|
if (provider.length === 0) {
|
|
102
142
|
const usages = ctx.registry.getExtensionUsages("fileProvider");
|
|
103
143
|
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|