@cosmicdrift/kumiko-samples 0.361.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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/document-ingest-foundation/aggregate-id.ts +10 -0
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/document-ingest-foundation/events.ts +3 -1
- package/packages/bundled-features/src/document-ingest-foundation/feature.ts +5 -3
- package/packages/bundled-features/src/document-ingest-foundation/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +7 -0
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +5 -1
- package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts +13 -1
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +6 -12
- package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +10 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/download-attempt.userdata-hook.ts +58 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +8 -2
- package/samples/apps/use-all-bundled/kumiko/migrations/0044_download-attempt-ip-bidx.rebuild.json +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.362.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -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>",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
|
|
3
|
+
// Fixed UUID namespace for document-ingest stream derivation. Frozen: changing
|
|
4
|
+
// it re-keys every existing ingest stream.
|
|
5
|
+
const DOCUMENT_INGEST_NAMESPACE = "5c1e8b7a-3d94-4f2a-b6e0-9a7d1c4f8e23";
|
|
6
|
+
|
|
7
|
+
// @wrapper-known uuid-domain
|
|
8
|
+
export function documentIngestAggregateId(tenantId: string, fileRefId: string): string {
|
|
9
|
+
return uuidv5(`${tenantId}|${fileRefId}`, DOCUMENT_INGEST_NAMESPACE);
|
|
10
|
+
}
|
|
@@ -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",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
|
|
3
3
|
// documentIngest — own aggregate-type for the ingest-request stream, one
|
|
4
|
-
// stream per fileRef
|
|
4
|
+
// stream per fileRef with aggregateId derived from the fileRefId
|
|
5
|
+
// (documentIngestAggregateId), never the fileRefId itself: foreign events on
|
|
6
|
+
// the fileRef stream would break the fileRef's optimistic lock. Not backed by an r.entity;
|
|
5
7
|
// this is a pure saga-anchor event (kumiko-framework#1497) that the Phase-2
|
|
6
8
|
// worker job (kumiko-enterprise LiteParse provider) subscribes to.
|
|
7
9
|
export const DOCUMENT_INGEST_AGGREGATE_TYPE = "document-ingest";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import { normalizeMimeType } from "@cosmicdrift/kumiko-framework/files";
|
|
16
16
|
import * as z from "zod";
|
|
17
|
+
import { documentIngestAggregateId } from "./aggregate-id.js";
|
|
17
18
|
import { validateDocumentIngestProviderWiring } from "./boot-checks.js";
|
|
18
19
|
import { documentExtractEntity } from "./entity.js";
|
|
19
20
|
import {
|
|
@@ -66,13 +67,14 @@ type FileRefIngestFields = z.infer<typeof fileRefIngestFieldsSchema>;
|
|
|
66
67
|
// delete→restore round-trip re-requests ingest instead of leaving the file
|
|
67
68
|
// without an extract.
|
|
68
69
|
async function requestIngestForFileRef(
|
|
69
|
-
event: { readonly aggregateId: string },
|
|
70
|
+
event: { readonly aggregateId: string; readonly tenantId: string },
|
|
70
71
|
fileRef: FileRefIngestFields,
|
|
71
72
|
ctx: MspApplyContext,
|
|
72
73
|
): Promise<void> {
|
|
74
|
+
const ingestAggregateId = documentIngestAggregateId(event.tenantId, event.aggregateId);
|
|
73
75
|
const skip = (reason: "file-too-large" | "unsupported-mime-type") =>
|
|
74
76
|
ctx.unsafeAppendEvent({
|
|
75
|
-
aggregateId:
|
|
77
|
+
aggregateId: ingestAggregateId,
|
|
76
78
|
aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
77
79
|
type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
78
80
|
payload: {
|
|
@@ -105,7 +107,7 @@ async function requestIngestForFileRef(
|
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
await ctx.unsafeAppendEvent({
|
|
108
|
-
aggregateId:
|
|
110
|
+
aggregateId: ingestAggregateId,
|
|
109
111
|
aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
110
112
|
type: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
|
|
111
113
|
payload: {
|
|
@@ -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",
|
|
@@ -76,7 +76,11 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
76
76
|
})
|
|
77
77
|
.optional(),
|
|
78
78
|
}),
|
|
79
|
-
|
|
79
|
+
// public-intake: rejected attempts store the anonymous caller's IP in the audit row.
|
|
80
|
+
access: {
|
|
81
|
+
roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"],
|
|
82
|
+
personalData: "public-intake",
|
|
83
|
+
},
|
|
80
84
|
agent: { expose: false },
|
|
81
85
|
// Brute-Force-Schutz fuer Token-Hash-Probing. Anonymous-Endpoint mit
|
|
82
86
|
// 32-byte-Random-Token = 256 Bit Search-Space, aber rate-limit als
|
package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-
|
|
|
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
|
|
|
7
8
|
// Operator-Query: invalid Download-Attempts (S2.U7).
|
|
8
9
|
// DPO-Sicht fuer Brute-Force-Detection. Tenant-isolated via WHERE.
|
|
9
10
|
|
|
10
11
|
const MAX_LIMIT = 100;
|
|
12
|
+
const REQUEST_ID = "user-data-rights:list-download-attempts";
|
|
11
13
|
|
|
12
14
|
export const listDownloadAttemptsQuery = defineQueryHandler({
|
|
13
15
|
name: "list-download-attempts",
|
|
@@ -44,6 +46,16 @@ export const listDownloadAttemptsQuery = defineQueryHandler({
|
|
|
44
46
|
limit: p.limit,
|
|
45
47
|
});
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
const decryptedRows = await Promise.all(
|
|
50
|
+
rows.map(async (row) => ({
|
|
51
|
+
...row,
|
|
52
|
+
ip: row.ip === null ? null : await decryptStoredPii(row.ip, "ip", REQUEST_ID),
|
|
53
|
+
userAgent:
|
|
54
|
+
row.userAgent === null
|
|
55
|
+
? null
|
|
56
|
+
: await decryptStoredPii(row.userAgent, "userAgent", REQUEST_ID),
|
|
57
|
+
})),
|
|
58
|
+
);
|
|
59
|
+
return { rows: decryptedRows };
|
|
48
60
|
},
|
|
49
61
|
});
|
|
@@ -25,6 +25,7 @@ export type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup.js";
|
|
|
25
25
|
export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
|
|
26
26
|
export type { UserExportBundle } from "./run-user-export.js";
|
|
27
27
|
export { runUserExport } from "./run-user-export.js";
|
|
28
|
+
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
|
|
28
29
|
export {
|
|
29
30
|
ACTIVE_JOB_CONSTRAINT,
|
|
30
31
|
EXPORT_JOB_STATUS,
|
|
@@ -35,23 +35,17 @@ export const downloadAttemptEntity = createEntity({
|
|
|
35
35
|
jobId: createTextField({ personal: false, reason: "technical_reference" }),
|
|
36
36
|
// User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
|
|
37
37
|
attemptedByUserId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// `personal: { of: "attemptedByUserId" }` would throw SubjectResolutionError
|
|
41
|
-
// on every such row once a KMS is configured
|
|
42
|
-
// (crypto/subject-resolver.ts resolveUserOwnedSubject).
|
|
43
|
-
// The job path knows the requester and an IP is personal data, so this is a
|
|
44
|
-
// deliberate waiver, not business data: a user forget does not shred these
|
|
45
|
-
// values, the 90d hardDelete below is what removes them.
|
|
38
|
+
// The anonymous token path has no owner, so those rows fall back to the tenant key;
|
|
39
|
+
// find "exact" on ip because list-download-attempts filters by IP.
|
|
46
40
|
ip: createTextField({
|
|
47
41
|
maxLength: 64,
|
|
48
|
-
personal:
|
|
49
|
-
|
|
42
|
+
personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
|
|
43
|
+
find: "exact",
|
|
50
44
|
}),
|
|
51
45
|
userAgent: createTextField({
|
|
52
46
|
maxLength: 256,
|
|
53
|
-
personal:
|
|
54
|
-
|
|
47
|
+
personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
|
|
48
|
+
find: "none",
|
|
55
49
|
}),
|
|
56
50
|
attemptedAt: createTimestampField({ required: true }),
|
|
57
51
|
},
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
deliveryAttemptDeleteHook,
|
|
13
13
|
deliveryAttemptExportHook,
|
|
14
14
|
} from "./hooks/delivery-attempt.userdata-hook.js";
|
|
15
|
+
import {
|
|
16
|
+
downloadAttemptDeleteHook,
|
|
17
|
+
downloadAttemptExportHook,
|
|
18
|
+
} from "./hooks/download-attempt.userdata-hook.js";
|
|
15
19
|
import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook.js";
|
|
16
20
|
import {
|
|
17
21
|
inAppMessageDeleteHook,
|
|
@@ -57,7 +61,7 @@ import {
|
|
|
57
61
|
export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
58
62
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
59
63
|
r.describe(
|
|
60
|
-
"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.",
|
|
64
|
+
"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.",
|
|
61
65
|
);
|
|
62
66
|
r.uiHints({
|
|
63
67
|
displayLabel: "User Data Rights · Default Hooks",
|
|
@@ -128,6 +132,11 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
|
128
132
|
delete: deliveryAttemptDeleteHook,
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
r.useExtension(EXT_USER_DATA, "download-attempt", {
|
|
136
|
+
export: downloadAttemptExportHook,
|
|
137
|
+
delete: downloadAttemptDeleteHook,
|
|
138
|
+
});
|
|
139
|
+
|
|
131
140
|
r.useExtension(EXT_USER_DATA, "job-run", {
|
|
132
141
|
export: jobRunExportHook,
|
|
133
142
|
delete: jobRunDeleteHook,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createSystemUser,
|
|
4
|
+
type UserDataDeleteHook,
|
|
5
|
+
type UserDataExportHook,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { collectErasureFailure, throwIfErasureFailed } from "../../shared/index.js";
|
|
8
|
+
import { downloadAttemptEntity, downloadAttemptsTable } from "../../user-data-rights/index.js";
|
|
9
|
+
|
|
10
|
+
// Event-sourced entity: forget goes through the executor so the erasure replays
|
|
11
|
+
// on rebuild and removes the projection row including ip_bidx.
|
|
12
|
+
const crud = createEventStoreExecutor(downloadAttemptsTable, downloadAttemptEntity, {
|
|
13
|
+
entityName: "download-attempt",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Only job-path attempts name a user; token-path rows have no owner and sit
|
|
17
|
+
// under the tenant key. ip may be ciphertext (kumiko-pii:), which the export
|
|
18
|
+
// runner's central decrypt sweep resolves.
|
|
19
|
+
export const downloadAttemptExportHook: UserDataExportHook = async (ctx) => {
|
|
20
|
+
const rows = await ctx.db.selectMany<Record<string, unknown>>(downloadAttemptsTable, {
|
|
21
|
+
tenantId: ctx.tenantId,
|
|
22
|
+
attemptedByUserId: ctx.userId,
|
|
23
|
+
});
|
|
24
|
+
if (rows.length === 0) return null;
|
|
25
|
+
return {
|
|
26
|
+
entity: "download-attempt",
|
|
27
|
+
rows: rows.map((r) => ({
|
|
28
|
+
result: r["result"],
|
|
29
|
+
via: r["via"],
|
|
30
|
+
ip: r["ip"],
|
|
31
|
+
userAgent: r["userAgent"],
|
|
32
|
+
attemptedAt: r["attemptedAt"],
|
|
33
|
+
})),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Both strategies purge: key erasure alone leaves attemptedByUserId and the
|
|
38
|
+
// blind index linking the user to the row until retention, and not every forget
|
|
39
|
+
// path shreds the subject key.
|
|
40
|
+
export const downloadAttemptDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
41
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
42
|
+
const rows = await ctx.db.selectMany<Record<string, unknown>>(downloadAttemptsTable, {
|
|
43
|
+
tenantId: ctx.tenantId,
|
|
44
|
+
attemptedByUserId: ctx.userId,
|
|
45
|
+
});
|
|
46
|
+
const failures: string[] = [];
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
const id = row["id"];
|
|
49
|
+
if (typeof id !== "string") continue;
|
|
50
|
+
collectErasureFailure(
|
|
51
|
+
await crud.forget({ id }, systemUser, ctx.db),
|
|
52
|
+
"download-attempt",
|
|
53
|
+
id,
|
|
54
|
+
failures,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
throwIfErasureFailed(failures);
|
|
58
|
+
};
|
|
@@ -467,7 +467,7 @@ export const leaseDetailGapsScreen: ProjectionDetailScreenDefinition = {
|
|
|
467
467
|
{
|
|
468
468
|
kind: "writeHandler",
|
|
469
469
|
id: "terminate",
|
|
470
|
-
icon: "
|
|
470
|
+
icon: "circle-stop",
|
|
471
471
|
label: "rental.action.terminate",
|
|
472
472
|
handler: RENTAL_WRITES.leaseTerminate,
|
|
473
473
|
payload: { pick: ["id"] },
|
|
@@ -1143,7 +1143,9 @@
|
|
|
1143
1143
|
"extensionsUsed": [],
|
|
1144
1144
|
"configKeys": [],
|
|
1145
1145
|
"secrets": [],
|
|
1146
|
-
"writeHandlers": [
|
|
1146
|
+
"writeHandlers": [
|
|
1147
|
+
"files:write:bind-referenced-files"
|
|
1148
|
+
],
|
|
1147
1149
|
"uiHints": {
|
|
1148
1150
|
"displayLabel": "Files · Metadata",
|
|
1149
1151
|
"category": "storage",
|
|
@@ -3008,7 +3010,7 @@
|
|
|
3008
3010
|
},
|
|
3009
3011
|
{
|
|
3010
3012
|
"name": "user-data-rights-defaults",
|
|
3011
|
-
"description": "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.",
|
|
3013
|
+
"description": "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.",
|
|
3012
3014
|
"toggleableDefault": null,
|
|
3013
3015
|
"requires": [
|
|
3014
3016
|
"user",
|
|
@@ -3064,6 +3066,10 @@
|
|
|
3064
3066
|
"extensionName": "userData",
|
|
3065
3067
|
"entityName": "delivery-attempt"
|
|
3066
3068
|
},
|
|
3069
|
+
{
|
|
3070
|
+
"extensionName": "userData",
|
|
3071
|
+
"entityName": "download-attempt"
|
|
3072
|
+
},
|
|
3067
3073
|
{
|
|
3068
3074
|
"extensionName": "userData",
|
|
3069
3075
|
"entityName": "job-run"
|