@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3
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 +108 -0
- package/package.json +12 -6
- 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/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- 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 +63 -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 +146 -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 +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- 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 +33 -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/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- 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 +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- 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 +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -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 +334 -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
|
@@ -18,19 +18,17 @@
|
|
|
18
18
|
//
|
|
19
19
|
// **Boot-Dependencies:** config + secrets + file-foundation.
|
|
20
20
|
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
FileProviderContext,
|
|
23
|
+
FileProviderPlugin,
|
|
24
|
+
} from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
22
25
|
import { createS3Provider } from "@cosmicdrift/kumiko-bundled-features/files-provider-s3";
|
|
23
26
|
import {
|
|
24
27
|
requireDefined,
|
|
25
28
|
requireNonEmpty,
|
|
26
29
|
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
27
30
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
28
|
-
import {
|
|
29
|
-
access,
|
|
30
|
-
createTenantConfig,
|
|
31
|
-
defineFeature,
|
|
32
|
-
type HandlerContext,
|
|
33
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
+
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
34
32
|
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
35
33
|
|
|
36
34
|
const FEATURE_NAME = "file-provider-s3";
|
|
@@ -89,7 +87,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
89
87
|
// Plugin-Registration. entityName "s3" ist was tenants in
|
|
90
88
|
// file-foundation's `provider` config-key setzen.
|
|
91
89
|
const plugin: FileProviderPlugin = {
|
|
92
|
-
build: async (ctx:
|
|
90
|
+
build: async (ctx: FileProviderContext, tenantId: string) => buildS3Provider(ctx, tenantId),
|
|
93
91
|
};
|
|
94
92
|
r.useExtension("fileProvider", "s3", plugin);
|
|
95
93
|
|
|
@@ -104,7 +102,7 @@ export const S3_SECRET_ACCESS_KEY = fileProviderS3Feature.exports.secretAccessKe
|
|
|
104
102
|
// =============================================================================
|
|
105
103
|
|
|
106
104
|
async function buildS3Provider(
|
|
107
|
-
ctx:
|
|
105
|
+
ctx: FileProviderContext,
|
|
108
106
|
tenantId: string,
|
|
109
107
|
): Promise<FileStorageProvider> {
|
|
110
108
|
const ctxConfig = ctx.config;
|
|
@@ -157,7 +155,7 @@ async function buildS3Provider(
|
|
|
157
155
|
});
|
|
158
156
|
}
|
|
159
157
|
|
|
160
|
-
async function readSecretAccessKey(ctx:
|
|
158
|
+
async function readSecretAccessKey(ctx: FileProviderContext, tenantId: string): Promise<string> {
|
|
161
159
|
const secrets = requireSecretsContext(ctx, FEATURE_NAME);
|
|
162
160
|
const branded = await secrets.get(tenantId, S3_SECRET_ACCESS_KEY);
|
|
163
161
|
if (!branded) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# files
|
|
2
|
+
|
|
3
|
+
Schema-Sicht der framework-internen `file_refs`-Tabelle als bundled-feature.
|
|
4
|
+
Sprint-1.5 Refactor (Pre-Sprint-2).
|
|
5
|
+
|
|
6
|
+
## Was es macht
|
|
7
|
+
|
|
8
|
+
Deklariert `r.entity("fileRef", ...)` für die DB-Tabelle die das
|
|
9
|
+
Framework via `createFileRoutes` (multipart-Upload + binary-Download)
|
|
10
|
+
bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
|
|
11
|
+
|
|
12
|
+
- **Sprint 2** (`user-data-rights-defaults`) registriert `r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })` — Forget-Flow + Daten-Export fassen die Files automatisch an. ✅ done
|
|
13
|
+
- **Sprint 5** (`tenant-lifecycle`) wird `r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })` registrieren — Tenant-Destroy löscht alle FileRefs.
|
|
14
|
+
|
|
15
|
+
## Was es NICHT macht
|
|
16
|
+
|
|
17
|
+
- **Keine Upload-/Download-Routes** — die bleiben in
|
|
18
|
+
`framework/src/api/server.ts` via `options.files`-Bootstrap.
|
|
19
|
+
Multipart-Form-Body und Binary-Streaming passen nicht ins Write/Query-
|
|
20
|
+
Handler-Pattern; ein Refactor zu `r.httpRoute` wäre orthogonal zu
|
|
21
|
+
diesem Sprint.
|
|
22
|
+
- **Kein eigener Drizzle-Table-Build** — die `file_refs`-Tabelle
|
|
23
|
+
existiert schon in `framework/src/files/file-ref-table.ts`. Diese
|
|
24
|
+
Entity ist nur die Schema-Sicht für Cross-Feature-Hooks; Drizzle-
|
|
25
|
+
Queries laufen weiter über `fileRefsTable` aus
|
|
26
|
+
`@cosmicdrift/kumiko-framework/files`.
|
|
27
|
+
|
|
28
|
+
## PII-Annotations (Sprint 0.1+0.7)
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
fileName → pii: true (Originalname enthält oft Personen-Bezug)
|
|
32
|
+
storageKey → allowPlaintext: "is-business-data" (interner UUID-Key)
|
|
33
|
+
mimeType → allowPlaintext: "is-business-data"
|
|
34
|
+
size → allowPlaintext: "is-business-data"
|
|
35
|
+
entityType → allowPlaintext: "is-business-data"
|
|
36
|
+
entityId → allowPlaintext: "is-business-data"
|
|
37
|
+
fieldName → allowPlaintext: "is-business-data"
|
|
38
|
+
insertedAt → kein PII-Marker (Audit-Timestamp, Framework-managed)
|
|
39
|
+
insertedById → allowPlaintext: "is-business-data" (User-Reference, kein Eigen-PII)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`fileName: pii: true` heißt: Sprint 3 Crypto-Shredding wird den Wert
|
|
43
|
+
mit dem Author-Subject-Key encrypten (für File-INHALTE: separates
|
|
44
|
+
Subject-Resolver-Pattern via `subjectField` — siehe storage-encryption.md
|
|
45
|
+
Sprint 4).
|
|
46
|
+
|
|
47
|
+
## Tests
|
|
48
|
+
|
|
49
|
+
`__tests__/files.integration.ts` — 5 Tests die beweisen dass die Feature-
|
|
50
|
+
Definition clean lädt + die PII-Markers + Tabellenname stimmen.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// files-feature integration tests (S1.5 + S1.7).
|
|
2
|
+
//
|
|
3
|
+
// Tests sortiert nach Verhaltens-Tiefe:
|
|
4
|
+
// 1. Feature-Definition Smoke (Boot-Validation passes)
|
|
5
|
+
// 2. Cross-Feature-Behavior: fileRef-Entity ist als Hook-Anker für
|
|
6
|
+
// Sprint-2-userData-Extension nutzbar
|
|
7
|
+
// 3. DDL-Konsistenz: Framework-pgTable + Feature-Entity zeigen auf
|
|
8
|
+
// dieselbe Postgres-Struktur (Drift-Guard)
|
|
9
|
+
// 4. Event-QN-Match: r.defineEvent + framework's fileUploadedEvent
|
|
10
|
+
// resolven zum selben QN
|
|
11
|
+
|
|
12
|
+
import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { FILE_UPLOADED_EVENT_TYPE, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
14
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { getTableColumns } from "drizzle-orm";
|
|
16
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
17
|
+
import { createFilesFeature, fileRefEntity } from "../feature";
|
|
18
|
+
|
|
19
|
+
let stack: TestStack;
|
|
20
|
+
|
|
21
|
+
const feature = createFilesFeature();
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
stack = await setupTestStack({ features: [feature] });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await stack.cleanup();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("files :: feature-definition smoke", () => {
|
|
32
|
+
test("Boot ist clean (PII-Annotations + Event-Schema valide)", async () => {
|
|
33
|
+
// setupTestStack hat das feature im beforeAll geladen — wenn Boot-
|
|
34
|
+
// Validation fehlschlägt, würde dieser Block nie laufen.
|
|
35
|
+
expect(stack).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("Tabellen-Name matched die Framework-pgTable (file_refs)", () => {
|
|
39
|
+
expect(fileRefEntity.table).toBe("file_refs");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("fileName ist als pii: true markiert", () => {
|
|
43
|
+
const fileName = fileRefEntity.fields["fileName"] as { pii?: boolean };
|
|
44
|
+
expect(fileName.pii).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("files :: cross-feature behavior (F1, S1.7)", () => {
|
|
49
|
+
test("Sprint-2-Pattern: ein Consumer-Feature kann r.useExtension(EXT_USER_DATA, fileRef, ...) registrieren", async () => {
|
|
50
|
+
// Stub-Feature simuliert die Sprint-2-Semantik: extendsRegistrar
|
|
51
|
+
// (kommt aus user-data-rights) + useExtension auf fileRef.
|
|
52
|
+
const userDataProvider = defineFeature("test-user-data-provider", (r) => {
|
|
53
|
+
r.extendsRegistrar(EXT_USER_DATA, {
|
|
54
|
+
// Spec-stub — Sprint 2 wird die echten Hook-Signaturen liefern.
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const consumer = defineFeature("test-files-consumer", (r) => {
|
|
59
|
+
r.requires("files", "test-user-data-provider");
|
|
60
|
+
r.useExtension(EXT_USER_DATA, "fileRef", {
|
|
61
|
+
// Stub-Hooks: in Sprint 2 werden diese die echte Forget-/Export-
|
|
62
|
+
// Logik tragen. Hier reicht: useExtension findet die fileRef-
|
|
63
|
+
// Entity in der Registry → kein Boot-Error.
|
|
64
|
+
export: async () => [],
|
|
65
|
+
delete: async () => undefined,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Eigener Test-Stack damit dieser Sub-Test isoliert vom outer
|
|
70
|
+
// beforeAll-Stack laeuft.
|
|
71
|
+
const crossStack = await setupTestStack({
|
|
72
|
+
features: [feature, userDataProvider, consumer],
|
|
73
|
+
});
|
|
74
|
+
expect(crossStack).toBeDefined();
|
|
75
|
+
await crossStack.cleanup();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("useExtension auf nicht-existierendes Entity wirft beim Boot", async () => {
|
|
79
|
+
const userDataProvider = defineFeature("test-user-data-provider-2", (r) => {
|
|
80
|
+
r.extendsRegistrar(EXT_USER_DATA, {});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const consumer = defineFeature("test-broken-consumer", (r) => {
|
|
84
|
+
r.requires("test-user-data-provider-2");
|
|
85
|
+
// GHOSTLY: useExtension auf "ghostEntity" gibt es nirgends.
|
|
86
|
+
r.useExtension(EXT_USER_DATA, "ghostEntity", {});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Boot soll laufen — es ist NICHT der Job des Boot-Validators zu
|
|
90
|
+
// pruefen ob die referenced Entity existiert (Extensions sind
|
|
91
|
+
// generisch, "ghostEntity" könnte ein App-Entity in einem anderen
|
|
92
|
+
// Feature sein). Test dokumentiert das aktuelle Verhalten als
|
|
93
|
+
// Regression-Guard. Sprint 2 schaerft ggf. mit
|
|
94
|
+
// validateUserDataExtensionTargets.
|
|
95
|
+
const ghostStack = await setupTestStack({
|
|
96
|
+
features: [userDataProvider, consumer],
|
|
97
|
+
});
|
|
98
|
+
expect(ghostStack).toBeDefined();
|
|
99
|
+
await ghostStack.cleanup();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("files :: DDL-Konsistenz (M3, S1.7)", () => {
|
|
104
|
+
// Drizzle's getTableColumns liefert die typed column-map ohne den
|
|
105
|
+
// Symbol-Properties-Junk. Sauberer als Object.keys(table) das auch
|
|
106
|
+
// interne Drizzle-Symbols mitnimmt.
|
|
107
|
+
function pgColumnNames(): Set<string> {
|
|
108
|
+
return new Set(Object.keys(getTableColumns(fileRefsTable)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test("Feature-Entity-Felder matchen die Framework-pgTable column-set", () => {
|
|
112
|
+
// Framework's fileRefsTable ist die Quelle der Wahrheit fuer die
|
|
113
|
+
// DB-Struktur. Feature-Entity ist Schema-Sicht. Beide muessen
|
|
114
|
+
// konsistent sein — sonst landet Sprint-2's userData-Hook in
|
|
115
|
+
// Drift-Hell beim Forget-Flow.
|
|
116
|
+
//
|
|
117
|
+
// Vergleich: alle Feature-Felder muessen als Spalten in der pgTable
|
|
118
|
+
// existieren (umgekehrt darf pgTable framework-managed Spalten haben
|
|
119
|
+
// wie tenantId/createdAt/updatedAt/deletedAt — die deklariert das
|
|
120
|
+
// Framework automatisch beim buildDrizzleTable-Mapping).
|
|
121
|
+
const pgColumns = pgColumnNames();
|
|
122
|
+
const featureFields = Object.keys(fileRefEntity.fields);
|
|
123
|
+
|
|
124
|
+
for (const field of featureFields) {
|
|
125
|
+
expect(
|
|
126
|
+
pgColumns.has(field),
|
|
127
|
+
`Feature-Field "${field}" fehlt in framework pgTable file_refs — Schema-Drift!`,
|
|
128
|
+
).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("Framework-pgTable hat die kritischen file_ref-Spalten (storageKey, fileName, mimeType, size)", () => {
|
|
133
|
+
const pgColumns = pgColumnNames();
|
|
134
|
+
expect(pgColumns.has("storageKey")).toBe(true);
|
|
135
|
+
expect(pgColumns.has("fileName")).toBe(true);
|
|
136
|
+
expect(pgColumns.has("mimeType")).toBe(true);
|
|
137
|
+
expect(pgColumns.has("size")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("files :: event-QN-match (M4, S1.7)", () => {
|
|
142
|
+
test("framework's fileUploadedEvent.name === 'files:event:uploaded'", () => {
|
|
143
|
+
// Wenn das Framework den Event-Namen aendert, fliegt dieser Test
|
|
144
|
+
// sofort an — und der QN aus r.defineEvent("uploaded") im feature
|
|
145
|
+
// wuerde nicht mehr matchen. Drift-Guard.
|
|
146
|
+
expect(FILE_UPLOADED_EVENT_TYPE).toBe("files:event:uploaded");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("Feature-Name 'files' + Event-Short 'uploaded' = QN 'files:event:uploaded'", () => {
|
|
150
|
+
// r.defineEvent("uploaded") in defineFeature("files", ...) resolved
|
|
151
|
+
// zu QN "files:event:uploaded" via Framework-Convention. Match
|
|
152
|
+
// garantiert dass framework's appendEvent + EventDef-Schema-
|
|
153
|
+
// Validation auf demselben QN landen.
|
|
154
|
+
const expected = `${feature.name}:event:uploaded`;
|
|
155
|
+
expect(expected).toBe(FILE_UPLOADED_EVENT_TYPE);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { fileUploadedPayloadSchema } from "@cosmicdrift/kumiko-framework/files";
|
|
3
|
+
import { fileRefEntity } from "./schema/file-ref";
|
|
4
|
+
|
|
5
|
+
export { fileRefEntity } from "./schema/file-ref";
|
|
6
|
+
|
|
7
|
+
// files — Schema-Sicht der framework-internen file_refs-Tabelle als
|
|
8
|
+
// bundled-feature, damit Cross-Feature-Hooks (userData, tenantData) sich
|
|
9
|
+
// an die "fileRef"-Entity hängen können.
|
|
10
|
+
//
|
|
11
|
+
// Sprint 1.5 (this commit):
|
|
12
|
+
// - r.entity("fileRef", fileRefEntity) — Schema-Surface
|
|
13
|
+
// - r.defineEvent("uploaded", schema) — Event-Marker
|
|
14
|
+
//
|
|
15
|
+
// Sprint 2 (kommt):
|
|
16
|
+
// - r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
|
|
17
|
+
//
|
|
18
|
+
// Sprint 5 (kommt):
|
|
19
|
+
// - r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
|
|
20
|
+
//
|
|
21
|
+
// Routes bleiben framework-internal (multipart-Upload + binary-Streaming
|
|
22
|
+
// passen nicht in das Handler-Pattern; siehe schema/file-ref.ts für
|
|
23
|
+
// Architektur-Note).
|
|
24
|
+
//
|
|
25
|
+
// Sprint-1.5-Plan-Roadmap-Wille: "fileRefsTable bleibt in framework
|
|
26
|
+
// (kein Daten-Move), aber r.entity('fileRef') deklariert sie für das
|
|
27
|
+
// Feature." — diese Datei IST die Umsetzung.
|
|
28
|
+
export function createFilesFeature(): FeatureDefinition {
|
|
29
|
+
return defineFeature("files", (r) => {
|
|
30
|
+
r.entity("fileRef", fileRefEntity);
|
|
31
|
+
|
|
32
|
+
r.defineEvent("uploaded", fileUploadedPayloadSchema);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createFilesFeature, fileRefEntity } from "./feature";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEntity,
|
|
3
|
+
createNumberField,
|
|
4
|
+
createTextField,
|
|
5
|
+
createTimestampField,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// fileRef — Schema-Sicht der File-Metadata-Tabelle aus dem Framework.
|
|
9
|
+
//
|
|
10
|
+
// Architektur-Entscheidung (Sprint 1.5):
|
|
11
|
+
//
|
|
12
|
+
// Die DB-Tabelle `file_refs` lebt weiterhin in
|
|
13
|
+
// `framework/src/files/file-ref-table.ts` als drizzle pgTable, weil
|
|
14
|
+
// die Hono-Upload-/Download-Routes (`createFileRoutes` in
|
|
15
|
+
// `framework/src/api/server.ts`) sie direkt nutzen. Multipart-Upload
|
|
16
|
+
// und Binary-Streaming passen nicht in das Write/Query-Handler-Pattern
|
|
17
|
+
// — Routes bleiben framework-internal.
|
|
18
|
+
//
|
|
19
|
+
// Was hier passiert: dieselbe DB-Tabelle wird zusätzlich als
|
|
20
|
+
// `r.entity("fileRef")` in einem bundled-feature deklariert. Das
|
|
21
|
+
// ermoeglicht:
|
|
22
|
+
// 1. r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
|
|
23
|
+
// in Sprint 2 — Forget-Flow + Daten-Export erkennen die Entity.
|
|
24
|
+
// 2. r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
|
|
25
|
+
// in Sprint 5 — Tenant-Lifecycle löscht alle FileRefs.
|
|
26
|
+
// 3. Boot-Validation für PII-Annotations greift (fileName, originalName).
|
|
27
|
+
//
|
|
28
|
+
// Kein buildDrizzleTable hier — die Mapping-Tabelle existiert schon im
|
|
29
|
+
// Framework. Drizzle-Reads in den Sprint-2+-Hooks gehen direkt über
|
|
30
|
+
// `fileRefsTable` aus `@cosmicdrift/kumiko-framework/files`.
|
|
31
|
+
//
|
|
32
|
+
// PII-Annotations (Sprint 0.1+0.7+1.7):
|
|
33
|
+
// - fileName → pii: true (Originalname enthält oft Personen-Bezug:
|
|
34
|
+
// "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf")
|
|
35
|
+
//
|
|
36
|
+
// Andere Felder brauchen KEINE Annotation:
|
|
37
|
+
// - storageKey, mimeType, size, entityType, entityId, fieldName,
|
|
38
|
+
// insertedById → keine PII-typischen Field-Namen, PII-Heuristik
|
|
39
|
+
// greift nicht (siehe boot-validator.ts PII_DIRECT_NAME_HINTS).
|
|
40
|
+
// Ein allowPlaintext-Marker wäre Über-Annotation ohne Effekt.
|
|
41
|
+
// - insertedAt → Audit-Timestamp, framework-managed.
|
|
42
|
+
//
|
|
43
|
+
// Tabellenname matched die Framework-pgTable damit r.entity-Reads über
|
|
44
|
+
// dieselbe Postgres-Tabelle laufen.
|
|
45
|
+
export const fileRefEntity = createEntity({
|
|
46
|
+
table: "file_refs",
|
|
47
|
+
fields: {
|
|
48
|
+
storageKey: createTextField({ required: true }),
|
|
49
|
+
fileName: createTextField({ required: true, pii: true }),
|
|
50
|
+
mimeType: createTextField({ required: true }),
|
|
51
|
+
size: createNumberField({ required: true }),
|
|
52
|
+
entityType: createTextField(),
|
|
53
|
+
entityId: createTextField(),
|
|
54
|
+
fieldName: createTextField(),
|
|
55
|
+
insertedAt: createTimestampField({ sortable: true, filterable: true }),
|
|
56
|
+
insertedById: createTextField(),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
1
2
|
import {
|
|
2
3
|
DeleteObjectCommand,
|
|
3
4
|
GetObjectCommand,
|
|
@@ -5,9 +6,45 @@ import {
|
|
|
5
6
|
PutObjectCommand,
|
|
6
7
|
S3Client,
|
|
7
8
|
} from "@aws-sdk/client-s3";
|
|
9
|
+
import { Upload } from "@aws-sdk/lib-storage";
|
|
8
10
|
import { getSignedUrl as presign } from "@aws-sdk/s3-request-presigner";
|
|
9
11
|
import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-framework/files";
|
|
10
12
|
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Operator-Pflicht-Setup (Multipart-Upload-Cleanup)
|
|
15
|
+
// =============================================================================
|
|
16
|
+
//
|
|
17
|
+
// `writeStream` nutzt @aws-sdk/lib-storage's Upload-class fuer echtes
|
|
18
|
+
// multipart-streaming. S3 created dabei eine Multipart-Upload-Session mit
|
|
19
|
+
// einer Upload-ID; bei normaler Completion wird sie via Complete-
|
|
20
|
+
// MultipartUpload geschlossen.
|
|
21
|
+
//
|
|
22
|
+
// **Edge-Case bei Worker-Abort:** wenn der Export-Worker mid-write gecancelt
|
|
23
|
+
// wird (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Multipart-
|
|
24
|
+
// Upload-Session in S3 OFFEN. S3 behaelt die bereits hochgeladenen Parts
|
|
25
|
+
// und berechnet Storage-Kosten dafuer — bis sie manuell oder via Lifecycle-
|
|
26
|
+
// Rule abgebrochen werden.
|
|
27
|
+
//
|
|
28
|
+
// **Pflicht-Operator-Setup auf jedem Bucket:**
|
|
29
|
+
//
|
|
30
|
+
// {
|
|
31
|
+
// "Rules": [{
|
|
32
|
+
// "ID": "AbortIncompleteMultipartUploads",
|
|
33
|
+
// "Status": "Enabled",
|
|
34
|
+
// "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 },
|
|
35
|
+
// "Filter": {}
|
|
36
|
+
// }]
|
|
37
|
+
// }
|
|
38
|
+
//
|
|
39
|
+
// AWS-CLI: `aws s3api put-bucket-lifecycle-configuration --bucket <name>
|
|
40
|
+
// --lifecycle-configuration file://lifecycle.json`. Hetzner Object Storage
|
|
41
|
+
// + R2 + Minio supporten dieselbe Syntax.
|
|
42
|
+
//
|
|
43
|
+
// **Code-side abort()** fuer graceful Worker-Shutdown ist follow-up. Das
|
|
44
|
+
// braucht Worker-Cancel-Semantik (AbortSignal-Propagation durch r.job),
|
|
45
|
+
// die im framework noch nicht existiert. Bis dahin ist die Lifecycle-
|
|
46
|
+
// Rule die einzige Garantie gegen Storage-Leakage.
|
|
47
|
+
|
|
11
48
|
// Minimal config surface — everything the SDK needs, nothing framework-
|
|
12
49
|
// specific. Apps wire this into `buildServer({ files: { storageProvider } })`
|
|
13
50
|
// the same way they'd pass createLocalProvider in dev.
|
|
@@ -62,6 +99,33 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
62
99
|
);
|
|
63
100
|
},
|
|
64
101
|
|
|
102
|
+
async writeStream(key, source, options): Promise<void> {
|
|
103
|
+
// Echtes multipart-streaming via @aws-sdk/lib-storage.Upload —
|
|
104
|
+
// der Source-AsyncIterable wird chunk-weise zu S3 hochgeladen,
|
|
105
|
+
// niemals alles im Memory aggregiert. lib-storage handled
|
|
106
|
+
// automatisch chunking (5MB-Parts default), parallel-uploads
|
|
107
|
+
// (4 concurrent default), und retry bei Part-Failures.
|
|
108
|
+
//
|
|
109
|
+
// Memory-Footprint: ~5MB pro in-flight-part × 4 concurrent =
|
|
110
|
+
// ~20MB Heap-Bound, unabhaengig von der Total-Bundle-Size. Macht
|
|
111
|
+
// 1GB+ Bundles moeglich ohne OOM.
|
|
112
|
+
//
|
|
113
|
+
// Readable.from(source) adapiert AsyncIterable → node:Readable —
|
|
114
|
+
// lib-storage's Body-Type akzeptiert Web-ReadableStream + node-
|
|
115
|
+
// Readable, nicht direkt AsyncIterable. Adapter ist zero-copy.
|
|
116
|
+
const body = Readable.from(source);
|
|
117
|
+
const upload = new Upload({
|
|
118
|
+
client,
|
|
119
|
+
params: {
|
|
120
|
+
Bucket: config.bucket,
|
|
121
|
+
Key: key,
|
|
122
|
+
Body: body,
|
|
123
|
+
...(options?.mimeType !== undefined && { ContentType: options.mimeType }),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
await upload.done();
|
|
127
|
+
},
|
|
128
|
+
|
|
65
129
|
async read(key): Promise<Uint8Array> {
|
|
66
130
|
const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
67
131
|
if (!response.Body) {
|
|
@@ -73,6 +137,31 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
73
137
|
return response.Body.transformToByteArray();
|
|
74
138
|
},
|
|
75
139
|
|
|
140
|
+
readStream(key): AsyncIterable<Uint8Array> {
|
|
141
|
+
// S3 GetObject.Body ist ein StreamingBlobPayloadOutputTypes — auf
|
|
142
|
+
// node ist das ein Readable-Stream der bereits AsyncIterable<Buffer>
|
|
143
|
+
// ist. Wir wrappen lazy: erst beim ersten chunk-pull wird der
|
|
144
|
+
// GetObject-Request abgesetzt. Wenn der Key nicht existiert, faellt
|
|
145
|
+
// der Error genau dort (nicht beim readStream-Aufruf) — gleiches
|
|
146
|
+
// Lazy-Verhalten wie inmemory + local.
|
|
147
|
+
return {
|
|
148
|
+
async *[Symbol.asyncIterator]() {
|
|
149
|
+
const response = await client.send(
|
|
150
|
+
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
151
|
+
);
|
|
152
|
+
if (!response.Body) {
|
|
153
|
+
throw new Error(`s3_read_empty_body: ${key}`);
|
|
154
|
+
}
|
|
155
|
+
// SdkStream is AsyncIterable<Buffer> on node. Buffer extends
|
|
156
|
+
// Uint8Array; cast sichert die Surface ohne neue runtime-deps.
|
|
157
|
+
const body = response.Body as AsyncIterable<Uint8Array>;
|
|
158
|
+
for await (const chunk of body) {
|
|
159
|
+
yield chunk;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
|
|
76
165
|
async delete(key): Promise<void> {
|
|
77
166
|
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
78
167
|
},
|
|
@@ -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:
|
|
@@ -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),
|
|
@@ -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
|
+
});
|