@cosmicdrift/kumiko-bundled-features 0.360.0 → 0.362.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/dist/audit/handlers/details.query.d.ts +1 -1
- package/dist/audit/handlers/details.query.js +2 -1
- package/dist/audit/handlers/list.query.d.ts +1 -1
- package/dist/audit/handlers/list.query.js +4 -5
- package/dist/auth-email-password/web/auth-client.d.ts +2 -0
- package/dist/auth-email-password/web/auth-client.js +3 -0
- package/dist/auth-email-password/web/session.js +19 -3
- package/dist/cap-counter/index.d.ts +1 -0
- package/dist/cap-counter/index.js +1 -0
- package/dist/data-retention/run-retention-cleanup.js +9 -6
- package/dist/document-ingest-foundation/aggregate-id.d.ts +1 -0
- package/dist/document-ingest-foundation/aggregate-id.js +8 -0
- package/dist/document-ingest-foundation/events.js +3 -1
- package/dist/document-ingest-foundation/feature.js +4 -2
- package/dist/document-ingest-foundation/index.d.ts +1 -0
- package/dist/document-ingest-foundation/index.js +1 -0
- package/dist/folders-user-data/hooks.js +4 -1
- package/dist/form-draft-user-data/hooks.js +4 -1
- package/dist/shared/encrypt-for-direct-write.js +2 -2
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/redact-pii-ciphertext.d.ts +2 -0
- package/dist/shared/redact-pii-ciphertext.js +14 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +3 -3
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/user/handlers/me.query.d.ts +3 -1
- package/dist/user/handlers/me.query.js +5 -1
- package/dist/user-data-rights/handlers/download-by-token.query.js +5 -1
- package/dist/user-data-rights/handlers/list-download-attempts.query.d.ts +1 -1
- package/dist/user-data-rights/handlers/list-download-attempts.query.js +10 -1
- package/dist/user-data-rights/handlers/my-audit-log.query.d.ts +1 -1
- package/dist/user-data-rights/handlers/my-audit-log.query.js +2 -1
- package/dist/user-data-rights/index.d.ts +1 -0
- package/dist/user-data-rights/index.js +1 -0
- package/dist/user-data-rights/schema/download-attempt.js +6 -12
- package/dist/user-data-rights-defaults/feature.js +6 -1
- package/dist/user-data-rights-defaults/hooks/download-attempt.userdata-hook.d.ts +3 -0
- package/dist/user-data-rights-defaults/hooks/download-attempt.userdata-hook.js +48 -0
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +2 -1
- package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +3 -2
- package/dist/user-data-rights-defaults/hooks/user.userdata-hook.js +3 -3
- package/package.json +9 -9
- package/src/audit/changes.json +6 -0
- package/src/cap-counter/changes.json +6 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/user/changes.json +6 -0
- package/src/user-data-rights/changes.json +7 -0
|
@@ -11,7 +11,7 @@ export declare const detailsQuery: import("@cosmicdrift/kumiko-types/define-hand
|
|
|
11
11
|
aggregateType: string;
|
|
12
12
|
version: number;
|
|
13
13
|
type: string;
|
|
14
|
-
payload:
|
|
14
|
+
payload: unknown;
|
|
15
15
|
metadata: Record<string, unknown>;
|
|
16
16
|
createdAt: unknown;
|
|
17
17
|
createdBy: string;
|
|
@@ -6,6 +6,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
6
6
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
+
import { redactPiiCiphertext } from "../../shared/index.js";
|
|
9
10
|
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
10
11
|
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
11
12
|
export const detailsQuery = defineQueryHandler({
|
|
@@ -34,7 +35,7 @@ export const detailsQuery = defineQueryHandler({
|
|
|
34
35
|
aggregateType: row.aggregateType,
|
|
35
36
|
version: row.version,
|
|
36
37
|
type: row.type,
|
|
37
|
-
payload: row.payload,
|
|
38
|
+
payload: redactPiiCiphertext(row.payload),
|
|
38
39
|
metadata: row.metadata,
|
|
39
40
|
createdAt: row.createdAt,
|
|
40
41
|
createdBy: row.createdBy,
|
|
@@ -29,7 +29,7 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
|
|
|
29
29
|
aggregateType: string;
|
|
30
30
|
version: number;
|
|
31
31
|
type: string;
|
|
32
|
-
payload:
|
|
32
|
+
payload: unknown;
|
|
33
33
|
metadata: Record<string, unknown>;
|
|
34
34
|
createdAt: unknown;
|
|
35
35
|
createdBy: string;
|
|
@@ -9,15 +9,14 @@
|
|
|
9
9
|
// A SystemAdmin can pass scope "system" to read the app-instance system events
|
|
10
10
|
// (e.g. app.started); no other cross-tenant read exists.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// query returns payloads as stored, so a sensitive value surfaces as ciphertext,
|
|
15
|
-
// never as plaintext.
|
|
12
|
+
// Event payloads hold PII as subject-KMS ciphertext; it is replaced by
|
|
13
|
+
// REDACTED_PII_PLACEHOLDER before returning, since the API leak guard refuses ciphertext.
|
|
16
14
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
15
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
16
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
17
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
20
18
|
import * as z from "zod";
|
|
19
|
+
import { redactPiiCiphertext } from "../../shared/index.js";
|
|
21
20
|
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
22
21
|
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
23
22
|
const MAX_LIMIT = 100;
|
|
@@ -102,7 +101,7 @@ export const listQuery = defineQueryHandler({
|
|
|
102
101
|
aggregateType: r.aggregateType,
|
|
103
102
|
version: r.version,
|
|
104
103
|
type: r.type,
|
|
105
|
-
payload: r.payload,
|
|
104
|
+
payload: redactPiiCiphertext(r.payload),
|
|
106
105
|
metadata: r.metadata,
|
|
107
106
|
createdAt: r.createdAt,
|
|
108
107
|
createdBy: r.createdBy,
|
|
@@ -135,6 +135,8 @@ export type CurrentUserProfile = {
|
|
|
135
135
|
readonly email: string;
|
|
136
136
|
readonly displayName: string;
|
|
137
137
|
readonly locale?: string;
|
|
138
|
+
/** Effective IANA zone (user override, else tenant, else UTC). */
|
|
139
|
+
readonly effectiveTimeZone?: string;
|
|
138
140
|
readonly globalRoles: readonly string[];
|
|
139
141
|
};
|
|
140
142
|
export declare function fetchCurrentUser(): Promise<CurrentUserProfile | null>;
|
|
@@ -295,6 +295,9 @@ export async function fetchCurrentUser() {
|
|
|
295
295
|
email: body.data.email,
|
|
296
296
|
displayName: body.data.displayName,
|
|
297
297
|
...(body.data.locale !== undefined && { locale: body.data.locale }),
|
|
298
|
+
...(body.data.effectiveTimeZone !== undefined && {
|
|
299
|
+
effectiveTimeZone: body.data.effectiveTimeZone,
|
|
300
|
+
}),
|
|
298
301
|
globalRoles: parseRoles(body.data.roles),
|
|
299
302
|
};
|
|
300
303
|
}
|
|
@@ -10,8 +10,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
10
10
|
// unter `<SessionProvider>`; der `useSession()`-Hook liefert den State
|
|
11
11
|
// und die Transitions.
|
|
12
12
|
import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
13
|
-
import { useSessionEndedSignal } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
-
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
13
|
+
import { CurrentUserProvider, useSessionEndedSignal, } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
|
|
15
15
|
import { AuthRequestError, fetchCurrentUser, fetchTenants, login as loginApi, logout as logoutApi, switchTenant as switchTenantApi, } from "./auth-client.js";
|
|
16
16
|
// Exported so screens that render on BOTH authenticated and anonymous public
|
|
17
17
|
// routes (e.g. InviteAcceptScreen) can read `.status` without requiring a
|
|
@@ -154,7 +154,23 @@ export function SessionProvider({ children, postLogoutUrl, }) {
|
|
|
154
154
|
}
|
|
155
155
|
}, []);
|
|
156
156
|
const api = { ...state, login, logout, switchTenant, refresh: doRefresh };
|
|
157
|
-
|
|
157
|
+
// Keyed on the primitives, not the profile object: every refresh (tenant
|
|
158
|
+
// switch, session bootstrap retry) builds a new profile object, and a new
|
|
159
|
+
// CurrentUser reference would re-seed a create form and drop typed input.
|
|
160
|
+
const profile = state.user;
|
|
161
|
+
const userId = profile?.id;
|
|
162
|
+
const userEmail = profile?.email;
|
|
163
|
+
const userDisplayName = profile?.displayName;
|
|
164
|
+
const userTimeZone = profile?.effectiveTimeZone;
|
|
165
|
+
const currentUser = useMemo(() => userId === undefined || userEmail === undefined || userDisplayName === undefined
|
|
166
|
+
? undefined
|
|
167
|
+
: {
|
|
168
|
+
id: userId,
|
|
169
|
+
email: userEmail,
|
|
170
|
+
displayName: userDisplayName,
|
|
171
|
+
...(userTimeZone !== undefined && { timeZone: userTimeZone }),
|
|
172
|
+
}, [userId, userEmail, userDisplayName, userTimeZone]);
|
|
173
|
+
return (_jsx(SessionContext.Provider, { value: api, children: _jsx(CurrentUserProvider, { user: currentUser, children: children }) }));
|
|
158
174
|
}
|
|
159
175
|
export function useSession() {
|
|
160
176
|
const ctx = useContext(SessionContext);
|
|
@@ -7,5 +7,6 @@ export { capCounterEntity } from "./entity.js";
|
|
|
7
7
|
export { capCounterFeature } from "./feature.js";
|
|
8
8
|
export { readPayloadField } from "./payload-field.js";
|
|
9
9
|
export { type CapGuardContext, type CapLimitContext, createStockCapGuard, type StockCapGuard, type StockCapSpec, } from "./stock-cap-guard.js";
|
|
10
|
+
export { capReservationsTable, capReservationsTableMeta } from "./tables.js";
|
|
10
11
|
export { createValueCapGuard, type ValueCapGuard, type ValueCapSpec, } from "./value-cap-guard.js";
|
|
11
12
|
export { type CalendarCapDef, type CalendarCapResolver, type RollingCapDef, type RollingCapResolver, withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
|
|
@@ -8,5 +8,6 @@ export { capCounterEntity } from "./entity.js";
|
|
|
8
8
|
export { capCounterFeature } from "./feature.js";
|
|
9
9
|
export { readPayloadField } from "./payload-field.js";
|
|
10
10
|
export { createStockCapGuard, } from "./stock-cap-guard.js";
|
|
11
|
+
export { capReservationsTable, capReservationsTableMeta } from "./tables.js";
|
|
11
12
|
export { createValueCapGuard, } from "./value-cap-guard.js";
|
|
12
13
|
export { withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
// einzelnen Page — sonst verstopfen erledigte Rows das batchLimit-Fenster.
|
|
40
40
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
41
41
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, isSelfPiiField, resolveSubjectForField, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
42
|
-
import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, } from "@cosmicdrift/kumiko-framework/db";
|
|
42
|
+
import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
|
|
43
43
|
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
44
44
|
import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
|
|
45
45
|
import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
|
|
@@ -149,7 +149,7 @@ async function forgetRowAndEraseKeys(args) {
|
|
|
149
149
|
}
|
|
150
150
|
const NO_FILE_FIELDS = { singleFields: [], multiFields: [] };
|
|
151
151
|
// file/image = single-column fields (fileRef-id lives directly in the entity
|
|
152
|
-
// row); files/images = plural,
|
|
152
|
+
// row); files/images = plural, resolved via file_refs. Same set
|
|
153
153
|
// as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
|
|
154
154
|
// isFileField — inlined here rather than importing across the package
|
|
155
155
|
// barrel since this module already needs the single/multi split, not just
|
|
@@ -169,8 +169,8 @@ function planFileFields(fields) {
|
|
|
169
169
|
: NO_FILE_FIELDS;
|
|
170
170
|
}
|
|
171
171
|
// Every fileRef-id a row's file/image/files/images-fields reference: single
|
|
172
|
-
// fields hold the id in their own column, plural fields
|
|
173
|
-
//
|
|
172
|
+
// fields hold the id in their own column, plural fields resolve
|
|
173
|
+
// via file_refs (tenantId, entityType, entityId, fieldName).
|
|
174
174
|
async function collectFileRefIds(db, tenantId, entityName, row, plan) {
|
|
175
175
|
const ids = new Set();
|
|
176
176
|
for (const field of plan.singleFields) {
|
|
@@ -536,7 +536,7 @@ export async function runRetentionCleanup(args) {
|
|
|
536
536
|
break;
|
|
537
537
|
}
|
|
538
538
|
const executor = createEventStoreExecutor(proj.table, entity, { entityName });
|
|
539
|
-
softDeleted += await purgeMatchingRows(db, proj.table, { ...where, isDeleted: false }, batchLimit, (id) => executor.delete({ id }, systemUser, tdb));
|
|
539
|
+
softDeleted += await purgeMatchingRows(db, proj.table, { ...where, isDeleted: false }, batchLimit, (id) => executor.delete({ id }, systemUser, tdb, { writePurpose: WritePurposes.retention }));
|
|
540
540
|
break;
|
|
541
541
|
}
|
|
542
542
|
// blockDelete = Legal-Hold: rows stay untouched during keepFor (the
|
|
@@ -560,7 +560,10 @@ export async function runRetentionCleanup(args) {
|
|
|
560
560
|
break;
|
|
561
561
|
}
|
|
562
562
|
const executor = createEventStoreExecutor(proj.table, entity, { entityName });
|
|
563
|
-
anonymized += await anonymizeMatchingRows(db, proj.table, where, batchLimit, targets, (id, changes) => executor.update({ id, changes }, systemUser, tdb, {
|
|
563
|
+
anonymized += await anonymizeMatchingRows(db, proj.table, where, batchLimit, targets, (id, changes) => executor.update({ id, changes }, systemUser, tdb, {
|
|
564
|
+
skipOptimisticLock: true,
|
|
565
|
+
writePurpose: WritePurposes.retention,
|
|
566
|
+
}));
|
|
564
567
|
break;
|
|
565
568
|
}
|
|
566
569
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function documentIngestAggregateId(tenantId: string, fileRefId: string): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
// Fixed UUID namespace for document-ingest stream derivation. Frozen: changing
|
|
3
|
+
// it re-keys every existing ingest stream.
|
|
4
|
+
const DOCUMENT_INGEST_NAMESPACE = "5c1e8b7a-3d94-4f2a-b6e0-9a7d1c4f8e23";
|
|
5
|
+
// @wrapper-known uuid-domain
|
|
6
|
+
export function documentIngestAggregateId(tenantId, fileRefId) {
|
|
7
|
+
return uuidv5(`${tenantId}|${fileRefId}`, DOCUMENT_INGEST_NAMESPACE);
|
|
8
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
// documentIngest — own aggregate-type for the ingest-request stream, one
|
|
3
|
-
// stream per fileRef
|
|
3
|
+
// stream per fileRef with aggregateId derived from the fileRefId
|
|
4
|
+
// (documentIngestAggregateId), never the fileRefId itself: foreign events on
|
|
5
|
+
// the fileRef stream would break the fileRef's optimistic lock. Not backed by an r.entity;
|
|
4
6
|
// this is a pure saga-anchor event (kumiko-framework#1497) that the Phase-2
|
|
5
7
|
// worker job (kumiko-enterprise LiteParse provider) subscribes to.
|
|
6
8
|
export const DOCUMENT_INGEST_AGGREGATE_TYPE = "document-ingest";
|
|
@@ -7,6 +7,7 @@ import { entityEventName } from "@cosmicdrift/kumiko-framework/db";
|
|
|
7
7
|
import { access, createTenantConfig, defineFeature, EXT_TENANT_DATA, } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { normalizeMimeType } from "@cosmicdrift/kumiko-framework/files";
|
|
9
9
|
import * as z from "zod";
|
|
10
|
+
import { documentIngestAggregateId } from "./aggregate-id.js";
|
|
10
11
|
import { validateDocumentIngestProviderWiring } from "./boot-checks.js";
|
|
11
12
|
import { documentExtractEntity } from "./entity.js";
|
|
12
13
|
import { DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.js";
|
|
@@ -36,8 +37,9 @@ const fileRefIngestFieldsSchema = z.object({
|
|
|
36
37
|
// delete→restore round-trip re-requests ingest instead of leaving the file
|
|
37
38
|
// without an extract.
|
|
38
39
|
async function requestIngestForFileRef(event, fileRef, ctx) {
|
|
40
|
+
const ingestAggregateId = documentIngestAggregateId(event.tenantId, event.aggregateId);
|
|
39
41
|
const skip = (reason) => ctx.unsafeAppendEvent({
|
|
40
|
-
aggregateId:
|
|
42
|
+
aggregateId: ingestAggregateId,
|
|
41
43
|
aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
42
44
|
type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
43
45
|
payload: {
|
|
@@ -66,7 +68,7 @@ async function requestIngestForFileRef(event, fileRef, ctx) {
|
|
|
66
68
|
return;
|
|
67
69
|
}
|
|
68
70
|
await ctx.unsafeAppendEvent({
|
|
69
|
-
aggregateId:
|
|
71
|
+
aggregateId: ingestAggregateId,
|
|
70
72
|
aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
71
73
|
type: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
|
|
72
74
|
payload: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { documentIngestAggregateId } from "./aggregate-id.js";
|
|
1
2
|
export { type DocumentExtractMeta, documentExtractEntity, documentExtractsTable, type IngestPage, } from "./entity.js";
|
|
2
3
|
export { DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, type DocumentIngestRequestedPayload, type DocumentIngestSkippedPayload, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.js";
|
|
3
4
|
export { documentIngestFoundationFeature } from "./feature.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Public API of the document-ingest-foundation bundled-feature.
|
|
2
|
+
export { documentIngestAggregateId } from "./aggregate-id.js";
|
|
2
3
|
export { documentExtractEntity, documentExtractsTable, } from "./entity.js";
|
|
3
4
|
export { DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.js";
|
|
4
5
|
export { documentIngestFoundationFeature } from "./feature.js";
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// consumers without the user-data-rights pipeline don't pull a hard dependency.
|
|
4
4
|
// Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
|
|
5
5
|
// (a folder name is tenant data, not per-user PII).
|
|
6
|
+
import { WritePurposes } from "@cosmicdrift/kumiko-framework/db";
|
|
6
7
|
import { createEntityExecutor, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { folderAssignmentEntity, folderEntity } from "../folders/index.js";
|
|
8
9
|
const { table: folderTable, executor: folderExecutor } = createEntityExecutor("folder", folderEntity);
|
|
@@ -48,7 +49,9 @@ function tenantScopedDelete(table, executor) {
|
|
|
48
49
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
49
50
|
const rows = await ctx.db.selectMany(table, { tenantId: ctx.tenantId });
|
|
50
51
|
for (const row of rows) {
|
|
51
|
-
await executor.delete({ id: row.id }, systemUser, ctx.db
|
|
52
|
+
await executor.delete({ id: row.id }, systemUser, ctx.db, {
|
|
53
|
+
writePurpose: WritePurposes.erasure,
|
|
54
|
+
});
|
|
52
55
|
}
|
|
53
56
|
};
|
|
54
57
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// (GDPR Art. 20 export / Art. 17 erasure). Lives apart from form-draft so
|
|
3
3
|
// consumers without the user-data-rights pipeline don't pull a hard
|
|
4
4
|
// dependency.
|
|
5
|
+
import { WritePurposes } from "@cosmicdrift/kumiko-framework/db";
|
|
5
6
|
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
import { formDraftExecutor, formDraftTable } from "../form-draft/index.js";
|
|
7
8
|
export const formDraftExportHook = async (ctx) => {
|
|
@@ -36,7 +37,9 @@ export const formDraftDeleteHook = async (ctx) => {
|
|
|
36
37
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
37
38
|
const failures = [];
|
|
38
39
|
for (const row of rows) {
|
|
39
|
-
const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db
|
|
40
|
+
const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db, {
|
|
41
|
+
writePurpose: WritePurposes.erasure,
|
|
42
|
+
});
|
|
40
43
|
if (!result.isSuccess) {
|
|
41
44
|
failures.push(`${row.id}: ${result.error.message}`);
|
|
42
45
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
-
import {
|
|
2
|
+
import { collectPiiEncryptedFields, configuredPiiSubjectKms, encryptPiiFieldValues, KeyErasedError, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { subjectErasedConflict } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
// Unmanaged direct-write stores (r.unmanagedTable) skip the executor, so its
|
|
5
5
|
// PII encryption never runs — every insert of subject-annotated fields must
|
|
@@ -10,7 +10,7 @@ export async function encryptForDirectWrite(entity, entityName, row, fallbackReq
|
|
|
10
10
|
if (!kms)
|
|
11
11
|
return row;
|
|
12
12
|
try {
|
|
13
|
-
return await encryptPiiFieldValues(row, entity,
|
|
13
|
+
return await encryptPiiFieldValues(row, entity, collectPiiEncryptedFields(entity), kms, { requestId: requestContext.get()?.requestId ?? fallbackRequestId }, { entityName });
|
|
14
14
|
}
|
|
15
15
|
catch (e) {
|
|
16
16
|
if (e instanceof KeyErasedError)
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
|
|
|
14
14
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
15
15
|
export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
|
|
16
16
|
export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
|
|
17
|
+
export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
|
|
17
18
|
export { requireForTenant } from "./require-for-tenant.js";
|
|
18
19
|
export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
|
|
19
20
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
package/dist/shared/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
|
|
|
14
14
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
15
15
|
export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
|
|
16
16
|
export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
|
|
17
|
+
export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
|
|
17
18
|
export { requireForTenant } from "./require-for-tenant.js";
|
|
18
19
|
export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
|
|
19
20
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
export const REDACTED_PII_PLACEHOLDER = "[[personal data]]";
|
|
3
|
+
// Event payloads store PII fields as subject-KMS ciphertext; the API leak guard
|
|
4
|
+
// rejects any response carrying it, so payload reads swap it for a placeholder.
|
|
5
|
+
export function redactPiiCiphertext(value) {
|
|
6
|
+
if (isPiiCiphertext(value))
|
|
7
|
+
return REDACTED_PII_PLACEHOLDER;
|
|
8
|
+
if (Array.isArray(value))
|
|
9
|
+
return value.map(redactPiiCiphertext);
|
|
10
|
+
if (typeof value === "object" && value !== null) {
|
|
11
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, redactPiiCiphertext(inner)]));
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
2
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
-
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { createEventStoreExecutor, createTenantDb, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { append, loadAggregate, VersionConflictError, } from "@cosmicdrift/kumiko-framework/event-store";
|
|
6
6
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -95,7 +95,7 @@ async function markTenantDestroyFailed(db, tenantId) {
|
|
|
95
95
|
await tenantCrud.update({
|
|
96
96
|
id: tenantId,
|
|
97
97
|
changes: { status: "destroyFailed" },
|
|
98
|
-
}, user, scopedDb, { skipOptimisticLock: true });
|
|
98
|
+
}, user, scopedDb, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
|
|
99
99
|
invalidateTenantLifecycleGate(tenantId);
|
|
100
100
|
}
|
|
101
101
|
async function haltPipelineOnAbandon(args) {
|
|
@@ -240,7 +240,7 @@ export async function runTenantDestructionSweep(args) {
|
|
|
240
240
|
// tenant stream, so an unguarded overwrite here would silently revert the
|
|
241
241
|
// cancellation. A version conflict means we lost that race — skip, the
|
|
242
242
|
// tenant is no longer due and the next tick re-evaluates it.
|
|
243
|
-
const result = await tenantCrud.update({ id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } }, user, scopedDb);
|
|
243
|
+
const result = await tenantCrud.update({ id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } }, user, scopedDb, { writePurpose: WritePurposes.erasure });
|
|
244
244
|
if (!result.isSuccess)
|
|
245
245
|
continue;
|
|
246
246
|
invalidateTenantLifecycleGate(tenantId);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { configuredPiiSubjectKms, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
-
import { createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { createEventStoreExecutor, createTenantDb, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createSystemUser, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, extensionUsageEscapeHatchReason, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
6
6
|
import { createEscapeHatchReporter, UNATTRIBUTED_ACTOR, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
@@ -132,7 +132,7 @@ async function tombstoneTenantRow(ctx) {
|
|
|
132
132
|
destroyedAt: now,
|
|
133
133
|
isEnabled: false,
|
|
134
134
|
},
|
|
135
|
-
}, user, db, { skipOptimisticLock: true });
|
|
135
|
+
}, user, db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
|
|
136
136
|
invalidateTenantLifecycleGate(ctx.tenantId);
|
|
137
137
|
return STAGE_DONE;
|
|
138
138
|
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
export declare const meQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"user:me", z.ZodObject<{}, z.core.$strip>,
|
|
2
|
+
export declare const meQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"user:me", z.ZodObject<{}, z.core.$strip>, {
|
|
3
|
+
effectiveTimeZone: string;
|
|
4
|
+
} | null, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -6,6 +6,7 @@ import { userEntity, userTable } from "../schema/user.js";
|
|
|
6
6
|
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
7
7
|
// Returns the currently signed-in user's profile. Field-level read access
|
|
8
8
|
// strips out the passwordHash automatically (configured on the entity).
|
|
9
|
+
// `effectiveTimeZone` is the effective IANA zone of the caller.
|
|
9
10
|
export const meQuery = defineQueryHandler({
|
|
10
11
|
name: "user:me",
|
|
11
12
|
schema: z.object({}),
|
|
@@ -26,6 +27,9 @@ export const meQuery = defineQueryHandler({
|
|
|
26
27
|
throw new InternalError({ message: "user:me requires r.systemScope()" });
|
|
27
28
|
}
|
|
28
29
|
const db = ctx.systemDb.acknowledgeCrossTenant("user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter");
|
|
29
|
-
|
|
30
|
+
const row = await crud.detail({ id: query.user.id }, query.user, db);
|
|
31
|
+
// `timezone` on the row is only the stored override (often unset); clients
|
|
32
|
+
// need the effective zone (override, else tenant, else UTC) to compute "today".
|
|
33
|
+
return row === null ? row : { ...row, effectiveTimeZone: ctx.tz.user };
|
|
30
34
|
},
|
|
31
35
|
});
|
|
@@ -48,7 +48,11 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
48
48
|
})
|
|
49
49
|
.optional(),
|
|
50
50
|
}),
|
|
51
|
-
|
|
51
|
+
// public-intake: rejected attempts store the anonymous caller's IP in the audit row.
|
|
52
|
+
access: {
|
|
53
|
+
roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"],
|
|
54
|
+
personalData: "public-intake",
|
|
55
|
+
},
|
|
52
56
|
agent: { expose: false },
|
|
53
57
|
// Brute-Force-Schutz fuer Token-Hash-Probing. Anonymous-Endpoint mit
|
|
54
58
|
// 32-byte-Random-Token = 256 Bit Search-Space, aber rate-limit als
|
|
@@ -11,5 +11,5 @@ export declare const listDownloadAttemptsQuery: import("@cosmicdrift/kumiko-type
|
|
|
11
11
|
from: z.ZodOptional<z.ZodISODateTime>;
|
|
12
12
|
to: z.ZodOptional<z.ZodISODateTime>;
|
|
13
13
|
}, z.core.$strip>, {
|
|
14
|
-
rows:
|
|
14
|
+
rows: any[];
|
|
15
15
|
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -2,10 +2,12 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
+
import { decryptStoredPii } from "../../shared/index.js";
|
|
5
6
|
import { downloadAttemptsTable } from "../schema/download-attempt.js";
|
|
6
7
|
// Operator-Query: invalid Download-Attempts (S2.U7).
|
|
7
8
|
// DPO-Sicht fuer Brute-Force-Detection. Tenant-isolated via WHERE.
|
|
8
9
|
const MAX_LIMIT = 100;
|
|
10
|
+
const REQUEST_ID = "user-data-rights:list-download-attempts";
|
|
9
11
|
export const listDownloadAttemptsQuery = defineQueryHandler({
|
|
10
12
|
name: "list-download-attempts",
|
|
11
13
|
schema: z
|
|
@@ -42,6 +44,13 @@ export const listDownloadAttemptsQuery = defineQueryHandler({
|
|
|
42
44
|
orderBy: { col: "attemptedAt", direction: "desc" },
|
|
43
45
|
limit: p.limit,
|
|
44
46
|
});
|
|
45
|
-
|
|
47
|
+
const decryptedRows = await Promise.all(rows.map(async (row) => ({
|
|
48
|
+
...row,
|
|
49
|
+
ip: row.ip === null ? null : await decryptStoredPii(row.ip, "ip", REQUEST_ID),
|
|
50
|
+
userAgent: row.userAgent === null
|
|
51
|
+
? null
|
|
52
|
+
: await decryptStoredPii(row.userAgent, "userAgent", REQUEST_ID),
|
|
53
|
+
})));
|
|
54
|
+
return { rows: decryptedRows };
|
|
46
55
|
},
|
|
47
56
|
});
|
|
@@ -13,7 +13,7 @@ export declare const myAuditLogQuery: import("@cosmicdrift/kumiko-types/define-h
|
|
|
13
13
|
aggregateType: string;
|
|
14
14
|
version: number;
|
|
15
15
|
type: string;
|
|
16
|
-
payload:
|
|
16
|
+
payload: unknown;
|
|
17
17
|
createdAt: unknown;
|
|
18
18
|
}[];
|
|
19
19
|
nextBefore: string | null;
|
|
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
5
5
|
import * as z from "zod";
|
|
6
|
+
import { redactPiiCiphertext } from "../../shared/index.js";
|
|
6
7
|
// DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
|
|
7
8
|
// WHERE createdBy = ctx.user.id ist hard-coded (kein userId-Param,
|
|
8
9
|
// anti-cross-user-Snooping). KEIN tenantId-Filter: User hat Anspruch
|
|
@@ -63,7 +64,7 @@ export const myAuditLogQuery = defineQueryHandler({
|
|
|
63
64
|
aggregateType: r["aggregate_type"],
|
|
64
65
|
version: r["version"],
|
|
65
66
|
type: r["type"],
|
|
66
|
-
payload: r["payload"],
|
|
67
|
+
payload: redactPiiCiphertext(r["payload"]),
|
|
67
68
|
createdAt: r["created_at"],
|
|
68
69
|
}));
|
|
69
70
|
const last = serialised[serialised.length - 1];
|
|
@@ -9,4 +9,5 @@ export type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup.js";
|
|
|
9
9
|
export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
|
|
10
10
|
export type { UserExportBundle } from "./run-user-export.js";
|
|
11
11
|
export { runUserExport } from "./run-user-export.js";
|
|
12
|
+
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
|
|
12
13
|
export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, type ExportJobStatus, exportJobEntity, exportJobsTable, } from "./schema/export-job.js";
|
|
@@ -15,4 +15,5 @@ export { backfillUserLifecycleEvents, updateUserLifecycle } from "./lib/update-u
|
|
|
15
15
|
// split.
|
|
16
16
|
export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
|
|
17
17
|
export { runUserExport } from "./run-user-export.js";
|
|
18
|
+
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
|
|
18
19
|
export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable, } from "./schema/export-job.js";
|
|
@@ -29,23 +29,17 @@ export const downloadAttemptEntity = createEntity({
|
|
|
29
29
|
jobId: createTextField({ personal: false, reason: "technical_reference" }),
|
|
30
30
|
// User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
|
|
31
31
|
attemptedByUserId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// `personal: { of: "attemptedByUserId" }` would throw SubjectResolutionError
|
|
35
|
-
// on every such row once a KMS is configured
|
|
36
|
-
// (crypto/subject-resolver.ts resolveUserOwnedSubject).
|
|
37
|
-
// The job path knows the requester and an IP is personal data, so this is a
|
|
38
|
-
// deliberate waiver, not business data: a user forget does not shred these
|
|
39
|
-
// values, the 90d hardDelete below is what removes them.
|
|
32
|
+
// The anonymous token path has no owner, so those rows fall back to the tenant key;
|
|
33
|
+
// find "exact" on ip because list-download-attempts filters by IP.
|
|
40
34
|
ip: createTextField({
|
|
41
35
|
maxLength: 64,
|
|
42
|
-
personal:
|
|
43
|
-
|
|
36
|
+
personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
|
|
37
|
+
find: "exact",
|
|
44
38
|
}),
|
|
45
39
|
userAgent: createTextField({
|
|
46
40
|
maxLength: 256,
|
|
47
|
-
personal:
|
|
48
|
-
|
|
41
|
+
personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
|
|
42
|
+
find: "none",
|
|
49
43
|
}),
|
|
50
44
|
attemptedAt: createTimestampField({ required: true }),
|
|
51
45
|
},
|
|
@@ -2,6 +2,7 @@ import { defineFeature, EXT_USER_DATA, } from "@cosmicdrift/kumiko-framework/eng
|
|
|
2
2
|
import { apiTokenDeleteHook, apiTokenExportHook } from "./hooks/api-token.userdata-hook.js";
|
|
3
3
|
import { configValueDeleteHook, configValueExportHook, } from "./hooks/config-value.userdata-hook.js";
|
|
4
4
|
import { deliveryAttemptDeleteHook, deliveryAttemptExportHook, } from "./hooks/delivery-attempt.userdata-hook.js";
|
|
5
|
+
import { downloadAttemptDeleteHook, downloadAttemptExportHook, } from "./hooks/download-attempt.userdata-hook.js";
|
|
5
6
|
import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook.js";
|
|
6
7
|
import { inAppMessageDeleteHook, inAppMessageExportHook, } from "./hooks/in-app-message.userdata-hook.js";
|
|
7
8
|
import { jobRunDeleteHook, jobRunExportHook } from "./hooks/job-run.userdata-hook.js";
|
|
@@ -33,7 +34,7 @@ import { userSessionDeleteHook, userSessionExportHook, } from "./hooks/user-sess
|
|
|
33
34
|
// export use. See hooks/file-ref.userdata-hook.ts.
|
|
34
35
|
export function createUserDataRightsDefaultsFeature() {
|
|
35
36
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
36
|
-
r.describe("Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding. Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.");
|
|
37
|
+
r.describe("Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding, and `download-attempt` (rejected export-download attempts of the user: result, path, source IP, user agent, time; delete purges the user's own attempts via the forget verb, token-path rows without an owner expire by retention). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.");
|
|
37
38
|
r.uiHints({
|
|
38
39
|
displayLabel: "User Data Rights · Default Hooks",
|
|
39
40
|
category: "compliance",
|
|
@@ -85,6 +86,10 @@ export function createUserDataRightsDefaultsFeature() {
|
|
|
85
86
|
export: deliveryAttemptExportHook,
|
|
86
87
|
delete: deliveryAttemptDeleteHook,
|
|
87
88
|
});
|
|
89
|
+
r.useExtension(EXT_USER_DATA, "download-attempt", {
|
|
90
|
+
export: downloadAttemptExportHook,
|
|
91
|
+
delete: downloadAttemptDeleteHook,
|
|
92
|
+
});
|
|
88
93
|
r.useExtension(EXT_USER_DATA, "job-run", {
|
|
89
94
|
export: jobRunExportHook,
|
|
90
95
|
delete: jobRunDeleteHook,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { collectErasureFailure, throwIfErasureFailed } from "../../shared/index.js";
|
|
4
|
+
import { downloadAttemptEntity, downloadAttemptsTable } from "../../user-data-rights/index.js";
|
|
5
|
+
// Event-sourced entity: forget goes through the executor so the erasure replays
|
|
6
|
+
// on rebuild and removes the projection row including ip_bidx.
|
|
7
|
+
const crud = createEventStoreExecutor(downloadAttemptsTable, downloadAttemptEntity, {
|
|
8
|
+
entityName: "download-attempt",
|
|
9
|
+
});
|
|
10
|
+
// Only job-path attempts name a user; token-path rows have no owner and sit
|
|
11
|
+
// under the tenant key. ip may be ciphertext (kumiko-pii:), which the export
|
|
12
|
+
// runner's central decrypt sweep resolves.
|
|
13
|
+
export const downloadAttemptExportHook = async (ctx) => {
|
|
14
|
+
const rows = await ctx.db.selectMany(downloadAttemptsTable, {
|
|
15
|
+
tenantId: ctx.tenantId,
|
|
16
|
+
attemptedByUserId: ctx.userId,
|
|
17
|
+
});
|
|
18
|
+
if (rows.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
entity: "download-attempt",
|
|
22
|
+
rows: rows.map((r) => ({
|
|
23
|
+
result: r["result"],
|
|
24
|
+
via: r["via"],
|
|
25
|
+
ip: r["ip"],
|
|
26
|
+
userAgent: r["userAgent"],
|
|
27
|
+
attemptedAt: r["attemptedAt"],
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
// Both strategies purge: key erasure alone leaves attemptedByUserId and the
|
|
32
|
+
// blind index linking the user to the row until retention, and not every forget
|
|
33
|
+
// path shreds the subject key.
|
|
34
|
+
export const downloadAttemptDeleteHook = async (ctx) => {
|
|
35
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
36
|
+
const rows = await ctx.db.selectMany(downloadAttemptsTable, {
|
|
37
|
+
tenantId: ctx.tenantId,
|
|
38
|
+
attemptedByUserId: ctx.userId,
|
|
39
|
+
});
|
|
40
|
+
const failures = [];
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
const id = row["id"];
|
|
43
|
+
if (typeof id !== "string")
|
|
44
|
+
continue;
|
|
45
|
+
collectErasureFailure(await crud.forget({ id }, systemUser, ctx.db), "download-attempt", id, failures);
|
|
46
|
+
}
|
|
47
|
+
throwIfErasureFailed(failures);
|
|
48
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { createEventStoreExecutor, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
3
3
|
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -184,6 +184,7 @@ async function severPersonLink(tdb, systemUser, rows) {
|
|
|
184
184
|
continue;
|
|
185
185
|
await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
|
|
186
186
|
skipOptimisticLock: true,
|
|
187
|
+
writePurpose: WritePurposes.erasure,
|
|
187
188
|
});
|
|
188
189
|
}
|
|
189
190
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { collectErasureFailure, decryptStoredPii, mapWithConcurrency, throwIfErasureFailed, } from "../../shared/index.js";
|
|
4
4
|
import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/index.js";
|
|
@@ -95,7 +95,7 @@ export const tenantInvitationDeleteHook = async (ctx, strategy) => {
|
|
|
95
95
|
else {
|
|
96
96
|
// Row-id in the pseudonym keeps the (tenantId, email) unique index
|
|
97
97
|
// collision-free when a user has invitations in several states.
|
|
98
|
-
await crud.update({ id, changes: { email: `forgotten-${id}@anonymized.invalid` } }, systemUser, ctx.db, { skipOptimisticLock: true });
|
|
98
|
+
await crud.update({ id, changes: { email: `forgotten-${id}@anonymized.invalid` } }, systemUser, ctx.db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -115,6 +115,7 @@ export const tenantInvitationDeleteHook = async (ctx, strategy) => {
|
|
|
115
115
|
continue;
|
|
116
116
|
await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, ctx.db, {
|
|
117
117
|
skipOptimisticLock: true,
|
|
118
|
+
writePurpose: WritePurposes.erasure,
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
121
|
throwIfErasureFailed(failures);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { USER_ANONYMIZED_DISPLAY_NAME, USER_ANONYMIZED_EMAIL_DOMAIN, USER_ANONYMIZED_EMAIL_PREFIX, USER_DELETED_DISPLAY_NAME, USER_DELETED_EMAIL_PREFIX, USER_STATUS, userEntity, userTable, } from "../../user/index.js";
|
|
4
4
|
// Forget writes go through the executor (events), not a raw UPDATE: a
|
|
@@ -72,7 +72,7 @@ export const userDeleteHook = async (ctx, strategy) => {
|
|
|
72
72
|
// Frozen into the event payload, so a rebuild replays the same value.
|
|
73
73
|
deletedAt: Temporal.Now.instant(),
|
|
74
74
|
},
|
|
75
|
-
}, systemUser, ctx.db, { skipOptimisticLock: true });
|
|
75
|
+
}, systemUser, ctx.db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
|
|
76
76
|
}
|
|
77
77
|
else {
|
|
78
78
|
// anonymize: PII raus, aber Row bleibt active (damit FK-References
|
|
@@ -86,6 +86,6 @@ export const userDeleteHook = async (ctx, strategy) => {
|
|
|
86
86
|
email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
|
|
87
87
|
displayName: USER_ANONYMIZED_DISPLAY_NAME,
|
|
88
88
|
},
|
|
89
|
-
}, systemUser, ctx.db, { skipOptimisticLock: true });
|
|
89
|
+
}, systemUser, ctx.db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
|
|
90
90
|
}
|
|
91
91
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.362.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -520,12 +520,12 @@
|
|
|
520
520
|
}
|
|
521
521
|
},
|
|
522
522
|
"dependencies": {
|
|
523
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
524
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
525
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
526
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
527
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
528
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
523
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.362.0",
|
|
524
|
+
"@cosmicdrift/kumiko-framework": "0.362.0",
|
|
525
|
+
"@cosmicdrift/kumiko-headless": "0.362.0",
|
|
526
|
+
"@cosmicdrift/kumiko-renderer": "0.362.0",
|
|
527
|
+
"@cosmicdrift/kumiko-renderer-web": "0.362.0",
|
|
528
|
+
"@cosmicdrift/kumiko-types": "0.362.0",
|
|
529
529
|
"@mollie/api-client": "^4.5.0",
|
|
530
530
|
"@node-rs/argon2": "^2.0.2",
|
|
531
531
|
"@types/mailparser": "^3.4.6",
|
|
@@ -1059,8 +1059,8 @@
|
|
|
1059
1059
|
],
|
|
1060
1060
|
"devDependencies": {
|
|
1061
1061
|
"@testing-library/user-event": "^14.6.1",
|
|
1062
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1063
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1062
|
+
"@cosmicdrift/kumiko-locale-de": "0.362.0",
|
|
1063
|
+
"@cosmicdrift/kumiko-locale-es": "0.362.0",
|
|
1064
1064
|
"jsqr": "^1.4.0",
|
|
1065
1065
|
"unpdf": "^1.8.1"
|
|
1066
1066
|
}
|
package/src/audit/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.361.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Audit log works under an active subject KMS",
|
|
6
|
+
"detail": "audit list, audit details and user-data-rights my-audit-log returned event payloads as stored. With a subject KMS configured the payload carried kumiko-pii ciphertext and the API leak guard answered 500 pii_ciphertext_leak. Every PII ciphertext string in the payload is now replaced by REDACTED_PII_PLACEHOLDER (\"[[personal data]]\"); the audit log still never shows plaintext PII."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.353.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.361.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "cap-counter exports capReservationsTable and capReservationsTableMeta",
|
|
6
|
+
"detail": "The reservation table store_cap_reservations was only reachable through internal imports, so kumiko-enterprise redeclared it in a test. The cap-counter entry now exports capReservationsTable and capReservationsTableMeta, following the feature-toggles pattern."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.358.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.362.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "document-ingest events live on their own stream",
|
|
6
|
+
"detail": "documentIngest.requested/skipped were appended to the fileRef's own event stream (aggregateId = fileRefId), so the request-ingest projection racing a save that binds a pending upload broke the fileRef's optimistic lock (409 version_conflict). They now use documentIngestAggregateId(tenantId, fileRefId); payload.fileRefId is unchanged."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|
package/src/user/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.361.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "user:me returns effectiveTimeZone, SessionProvider provides the current user",
|
|
6
|
+
"detail": "The me query adds effectiveTimeZone (ctx.tz.user), CurrentUserProfile.effectiveTimeZone is parsed by fetchCurrentUser and SessionProvider wraps its children in CurrentUserProvider."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.362.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "download-attempt.ip and userAgent are encrypted personal data instead of plaintext",
|
|
6
|
+
"detail": "download-attempt.ip and userAgent are declared personal: { of: \"attemptedByUserId\", whenAbsent: \"tenant\" }, ip with find \"exact\" and userAgent with find \"none\". With a KMS configured, rows written by the logged-in job path are encrypted under the requesting user's key (a user forget makes IP and user agent unreadable), rows from the anonymous token path under the tenant key. The new ip_bidx column keeps the equality filter of list-download-attempts working across both kinds of rows, and the query now returns the decrypted values (an erased user's values come back as the erased sentinel). user-data-rights-defaults registers an EXT_USER_DATA hook for download-attempt: export lists the user's own attempts, delete purges the user's own attempts via the forget verb (projection row and blind index included, replayed on rebuild); owner-less token-path rows expire with the 90d hardDelete retention.",
|
|
7
|
+
"migration": "Apps that mount user-data-rights run kumiko-schema generate and apply: the migration adds the ip_bidx column and its index to read_download_attempts. Apps that mount user-data-rights without user-data-rights-defaults must register their own EXT_USER_DATA hook for the download-attempt entity, otherwise boot fails the PII hook coverage check. The annotation fingerprint of the boot PII backfill changes, so the first boot after the bump does one full pass: it encrypts existing download-attempt events (tenant key or user key) and rebuilds the projection including ip_bidx (KUMIKO_SKIP_PII_BACKFILL=1 turns it off)."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.354.0",
|
|
4
11
|
"type": "breaking",
|