@cosmicdrift/kumiko-bundled-features 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.
@@ -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 (aggregateId = fileRefId). Not backed by an r.entity;
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: event.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: event.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";
@@ -48,7 +48,11 @@ export const downloadByTokenQuery = defineQueryHandler({
48
48
  })
49
49
  .optional(),
50
50
  }),
51
- access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
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: readonly any[];
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
- return { rows };
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
  });
@@ -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
- // No subject annotation on this buildEntityTable-managed entity:
33
- // attemptedByUserId is null-by-design for the anonymous token path, so
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: false,
43
- reason: "security_audit_retention_bounded",
36
+ personal: { of: "attemptedByUserId", whenAbsent: "tenant" },
37
+ find: "exact",
44
38
  }),
45
39
  userAgent: createTextField({
46
40
  maxLength: 256,
47
- personal: false,
48
- reason: "security_audit_retention_bounded",
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,3 @@
1
+ import { type UserDataDeleteHook, type UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare const downloadAttemptExportHook: UserDataExportHook;
3
+ export declare const downloadAttemptDeleteHook: UserDataDeleteHook;
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.361.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.361.0",
524
- "@cosmicdrift/kumiko-framework": "0.361.0",
525
- "@cosmicdrift/kumiko-headless": "0.361.0",
526
- "@cosmicdrift/kumiko-renderer": "0.361.0",
527
- "@cosmicdrift/kumiko-renderer-web": "0.361.0",
528
- "@cosmicdrift/kumiko-types": "0.361.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.361.0",
1063
- "@cosmicdrift/kumiko-locale-es": "0.361.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
  }
@@ -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,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",