@cosmicdrift/kumiko-bundled-features 0.187.0 → 0.188.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.
Files changed (29) hide show
  1. package/package.json +9 -7
  2. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +1 -1
  3. package/src/custom-fields/__tests__/retention.integration.test.ts +2 -2
  4. package/src/form-draft/__tests__/cleanup.integration.test.ts +254 -0
  5. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +165 -0
  6. package/src/form-draft/__tests__/form-draft.integration.test.ts +232 -0
  7. package/src/form-draft/changes.json +1 -0
  8. package/src/form-draft/constants.ts +31 -0
  9. package/src/form-draft/db/queries/cleanup.ts +50 -0
  10. package/src/form-draft/db/queries/owned-file-refs.ts +26 -0
  11. package/src/form-draft/entity.ts +51 -0
  12. package/src/form-draft/executor.ts +7 -0
  13. package/src/form-draft/feature.ts +42 -0
  14. package/src/form-draft/handlers/cleanup.job.ts +120 -0
  15. package/src/form-draft/handlers/discard.write.ts +55 -0
  16. package/src/form-draft/handlers/get.query.ts +26 -0
  17. package/src/form-draft/handlers/list.query.ts +40 -0
  18. package/src/form-draft/handlers/save.write.ts +60 -0
  19. package/src/form-draft/i18n.ts +3 -0
  20. package/src/form-draft/index.ts +28 -0
  21. package/src/form-draft/lookup.ts +53 -0
  22. package/src/form-draft/release-file-refs.ts +69 -0
  23. package/src/form-draft/schemas.ts +47 -0
  24. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +146 -0
  25. package/src/form-draft-user-data/changes.json +1 -0
  26. package/src/form-draft-user-data/hooks.ts +49 -0
  27. package/src/form-draft-user-data/index.ts +21 -0
  28. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +1 -1
  29. package/src/user-data-rights/__tests__/boot-checks.test.ts +14 -0
@@ -0,0 +1,40 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { FORM_DRAFT_ACCESS } from "../constants";
3
+ import { listDraftsByScreen } from "../lookup";
4
+ import { listDraftsPayloadSchema } from "../schemas";
5
+
6
+ export type ListDraftsResult = {
7
+ readonly drafts: readonly {
8
+ readonly id: string;
9
+ readonly draftKey: string;
10
+ readonly stepIndex: number;
11
+ readonly savedAt: string;
12
+ }[];
13
+ };
14
+
15
+ // list — the fallback path for resuming a draft whose draftId the client
16
+ // lost (new tab, cleared storage, another device): returns just enough to
17
+ // pick one (id, draftKey, stepIndex, savedAt), never the blob's `values`.
18
+ // Newest first, so the most likely-relevant draft leads.
19
+ export const listDraftsQuery = defineQueryHandler({
20
+ name: "list",
21
+ schema: listDraftsPayloadSchema,
22
+ access: FORM_DRAFT_ACCESS,
23
+ handler: async (query, ctx): Promise<ListDraftsResult> => {
24
+ const rows = await listDraftsByScreen(
25
+ ctx.db,
26
+ query.user.tenantId,
27
+ query.user.id,
28
+ query.payload.screenId,
29
+ );
30
+ const drafts = rows
31
+ .map((row) => ({
32
+ id: row.id,
33
+ draftKey: row.draftKey,
34
+ stepIndex: row.draft.stepIndex,
35
+ savedAt: row.draft.savedAt,
36
+ }))
37
+ .sort((a, b) => b.savedAt.localeCompare(a.savedAt));
38
+ return { drafts };
39
+ },
40
+ });
@@ -0,0 +1,60 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
4
+ import { FORM_DRAFT_ACCESS, FORM_DRAFT_UNIQUE_KEY_CONSTRAINT } from "../constants";
5
+ import { formDraftExecutor } from "../executor";
6
+ import { lookupDraft } from "../lookup";
7
+ import { saveDraftPayloadSchema } from "../schemas";
8
+
9
+ /**
10
+ * save is an upsert keyed on (tenantId, ownerId, draftKey) — same
11
+ * lookup-then-create|update-with-race-retry idiom as
12
+ * delivery/upsert-preference.ts, because createEventStoreExecutor has no
13
+ * built-in upsert. Two racing saves for the same draftKey: the loser hits
14
+ * the unique index (FORM_DRAFT_UNIQUE_KEY_CONSTRAINT), re-looks-up the
15
+ * winner's row, and updates it (with the winner's version) instead of
16
+ * erroring.
17
+ */
18
+ function isDraftKeyConflict(failure: WriteFailure): boolean {
19
+ const error = failure.error as {
20
+ code?: string;
21
+ details?: { constraintName?: string };
22
+ }; // @cast-boundary error-details
23
+ return (
24
+ error.code === "unique_violation" &&
25
+ error.details?.constraintName === FORM_DRAFT_UNIQUE_KEY_CONSTRAINT
26
+ );
27
+ }
28
+
29
+ export const saveDraftWrite = defineWriteHandler({
30
+ name: "save",
31
+ schema: saveDraftPayloadSchema,
32
+ access: FORM_DRAFT_ACCESS,
33
+ handler: async (event, ctx) => {
34
+ const ownerId = event.user.id;
35
+ const { draftKey, values, stepIndex } = event.payload;
36
+ const T = getTemporal();
37
+ const draft = { values, stepIndex, savedAt: T.Now.instant().toString() };
38
+
39
+ const existing = await lookupDraft(ctx.db, event.user.tenantId, ownerId, draftKey);
40
+ if (existing) {
41
+ return formDraftExecutor.update(
42
+ { id: existing.id, version: existing.version, changes: { draft } },
43
+ event.user,
44
+ ctx.db,
45
+ );
46
+ }
47
+
48
+ const result = await formDraftExecutor.create({ ownerId, draftKey, draft }, event.user, ctx.db);
49
+ if (result.isSuccess) return result;
50
+ if (!isDraftKeyConflict(result)) return result;
51
+
52
+ const winner = await lookupDraft(ctx.db, event.user.tenantId, ownerId, draftKey);
53
+ if (!winner) return result;
54
+ return formDraftExecutor.update(
55
+ { id: winner.id, version: winner.version, changes: { draft } },
56
+ event.user,
57
+ ctx.db,
58
+ );
59
+ },
60
+ });
@@ -0,0 +1,3 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const FORM_DRAFT_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {};
@@ -0,0 +1,28 @@
1
+ export {
2
+ FORM_DRAFT_ACCESS,
3
+ FORM_DRAFT_FEATURE_NAME,
4
+ FORM_DRAFT_KEY_MAX_LENGTH,
5
+ FORM_DRAFT_UNIQUE_KEY_CONSTRAINT,
6
+ FormDraftHandlers,
7
+ FormDraftQueries,
8
+ } from "./constants";
9
+ export { formDraftEntity } from "./entity";
10
+ export { formDraftExecutor, formDraftTable } from "./executor";
11
+ export { formDraftFeature } from "./feature";
12
+ export { discardDraftWrite } from "./handlers/discard.write";
13
+ export { type GetDraftResult, getDraftQuery } from "./handlers/get.query";
14
+ export { type ListDraftsResult, listDraftsQuery } from "./handlers/list.query";
15
+ export { saveDraftWrite } from "./handlers/save.write";
16
+ export { type FormDraftRow, lookupDraft } from "./lookup";
17
+ export {
18
+ type DiscardDraftPayload,
19
+ discardDraftPayloadSchema,
20
+ type FormDraftBlob,
21
+ formDraftBlobSchema,
22
+ type GetDraftPayload,
23
+ getDraftPayloadSchema,
24
+ type ListDraftsPayload,
25
+ listDraftsPayloadSchema,
26
+ type SaveDraftPayload,
27
+ saveDraftPayloadSchema,
28
+ } from "./schemas";
@@ -0,0 +1,53 @@
1
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { formDraftTable } from "./executor";
5
+ import type { FormDraftBlob } from "./schemas";
6
+
7
+ export type FormDraftRow = {
8
+ readonly id: string;
9
+ readonly version: number;
10
+ readonly draft: FormDraftBlob;
11
+ };
12
+
13
+ // Shared by save (upsert lookup + race re-lookup), discard, and get — always
14
+ // scoped by tenantId + ownerId + draftKey, tenantId included explicitly
15
+ // (not relying on TenantDb auto-scoping) to mirror delivery/upsert-preference.ts.
16
+ export async function lookupDraft(
17
+ db: TenantDb,
18
+ tenantId: TenantId,
19
+ ownerId: string,
20
+ draftKey: string,
21
+ ): Promise<FormDraftRow | undefined> {
22
+ return fetchOne<FormDraftRow>(db, formDraftTable, { tenantId, ownerId, draftKey });
23
+ }
24
+
25
+ export type FormDraftSummaryRow = {
26
+ readonly id: string;
27
+ readonly draftKey: string;
28
+ readonly draft: FormDraftBlob;
29
+ };
30
+
31
+ // LIKE metacharacters in a caller-controlled screenId must not widen the
32
+ // prefix scan (e.g. a literal "%" matching every draft).
33
+ function escapeLikePattern(value: string): string {
34
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
35
+ }
36
+
37
+ // draftKey is `${screenId}:${entityId}` or `${screenId}:new:${draftId}`
38
+ // (framework-wizard-mode.md) — "drafts for a screenId" is a LIKE prefix
39
+ // match on that leading segment. tenantId + ownerId equality narrows to the
40
+ // caller's own rows via the (tenantId, ownerId, draftKey) unique index
41
+ // before the pattern filter applies.
42
+ export async function listDraftsByScreen(
43
+ db: TenantDb,
44
+ tenantId: TenantId,
45
+ ownerId: string,
46
+ screenId: string,
47
+ ): Promise<readonly FormDraftSummaryRow[]> {
48
+ return selectMany<FormDraftSummaryRow>(db, formDraftTable, {
49
+ tenantId,
50
+ ownerId,
51
+ draftKey: { like: `${escapeLikePattern(screenId)}:%` },
52
+ });
53
+ }
@@ -0,0 +1,69 @@
1
+ import type { FormDraftBlob } from "./schemas";
2
+
3
+ // The draft blob's `values` is free-form by design (issue #1889: form-draft
4
+ // never knows the consuming form's field schema, only { values, stepIndex,
5
+ // savedAt }). A FileRef pointer is therefore recognised purely by shape —
6
+ // any object anywhere in `values` carrying a non-empty string `storageKey`
7
+ // matches what POST /files returns (file-routes.ts) and what a photo-upload
8
+ // step writes into the draft, whether under a single "file" field or nested
9
+ // in a "files"/"images" array.
10
+ function isFileRefPointer(value: unknown): value is { readonly storageKey: string } {
11
+ if (typeof value !== "object" || value === null) return false;
12
+ const storageKey = (value as { storageKey?: unknown }).storageKey;
13
+ return typeof storageKey === "string" && storageKey.length > 0;
14
+ }
15
+
16
+ function collectStorageKeys(value: unknown, keys: Set<string>): void {
17
+ if (isFileRefPointer(value)) {
18
+ keys.add(value.storageKey);
19
+ // skip: FileRef pointer is a leaf — nothing further to recurse into.
20
+ return;
21
+ }
22
+ if (Array.isArray(value)) {
23
+ for (const item of value) collectStorageKeys(item, keys);
24
+ // skip: array items are already visited recursively above.
25
+ return;
26
+ }
27
+ if (typeof value === "object" && value !== null) {
28
+ for (const item of Object.values(value)) collectStorageKeys(item, keys);
29
+ }
30
+ }
31
+
32
+ export function collectDraftFileRefKeys(
33
+ draft: FormDraftBlob | null | undefined,
34
+ ): readonly string[] {
35
+ if (!draft) return [];
36
+ const keys = new Set<string>();
37
+ collectStorageKeys(draft.values, keys);
38
+ return [...keys];
39
+ }
40
+
41
+ // Best-effort by design: a storage hiccup must not block a draft discard or
42
+ // the nightly cleanup sweep — this is storage-cost hygiene, not GDPR Art. 17
43
+ // erasure, so there is no fail-closed requirement (contrast
44
+ // user-data-rights-defaults/hooks/file-ref.userdata-hook.ts, which rolls
45
+ // back the whole forget on a binary-delete failure). Each failing key is
46
+ // logged and skipped; the caller's row-level operation proceeds regardless.
47
+ //
48
+ // Takes an already-decided key list, not the draft blob — callers must run
49
+ // collectDraftFileRefKeys() output through db/queries' owned-storage-key
50
+ // filter first. `values` is free-form, client-supplied JSON (issue #1889);
51
+ // releasing a key straight out of it without verifying a real, owned
52
+ // file_refs row exists would let a crafted `{ storageKey: "<victim's key>" }`
53
+ // delete someone else's file — the same ownership check file-routes.ts runs
54
+ // before every read/delete (loadFileForTenant + FileAccessGuard).
55
+ export async function releaseDraftFileRefs(
56
+ keys: readonly string[],
57
+ deleteBinary: (storageKey: string) => Promise<void>,
58
+ log?: { warn(msg: string, data?: Record<string, unknown>): void },
59
+ ): Promise<void> {
60
+ for (const key of keys) {
61
+ try {
62
+ await deleteBinary(key);
63
+ } catch (err) {
64
+ log?.warn(`[form-draft] failed to release FileRef storageKey=${key}`, {
65
+ error: err instanceof Error ? err.message : String(err),
66
+ });
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { FORM_DRAFT_KEY_MAX_LENGTH } from "./constants";
3
+
4
+ const draftKeySchema = z.string().trim().min(1).max(FORM_DRAFT_KEY_MAX_LENGTH);
5
+
6
+ // The draft blob shape — fixed by issue #1889 here, not left for consumers
7
+ // to invent per-app. `stepIndex` is in from the start so wizard-mode resume
8
+ // (kumiko-framework#1884) doesn't need a later migration.
9
+ export const formDraftBlobSchema = z.object({
10
+ values: z.record(z.string(), z.unknown()),
11
+ stepIndex: z.number().int().min(0),
12
+ savedAt: z.string(),
13
+ });
14
+ export type FormDraftBlob = z.infer<typeof formDraftBlobSchema>;
15
+
16
+ // savedAt is stamped server-side (see handlers/save.write.ts) — never
17
+ // accepted from the caller.
18
+ export const saveDraftPayloadSchema = z.object({
19
+ draftKey: draftKeySchema,
20
+ values: z.record(z.string(), z.unknown()),
21
+ stepIndex: z.number().int().min(0),
22
+ });
23
+ export type SaveDraftPayload = z.infer<typeof saveDraftPayloadSchema>;
24
+
25
+ // releaseFiles defaults to false/absent: the caller's only two current call
26
+ // sites (render-edit.tsx, both on a *successful submit*) delete the draft
27
+ // row right after the domain entity write already carries the same
28
+ // storageKeys forward — releasing them here would destroy files the entity
29
+ // now depends on. Only an actual abandon/abort flow should pass
30
+ // releaseFiles: true (issue #1915's "explicit abort" case).
31
+ export const discardDraftPayloadSchema = z.object({
32
+ draftKey: draftKeySchema,
33
+ releaseFiles: z.boolean().optional(),
34
+ });
35
+ export type DiscardDraftPayload = z.infer<typeof discardDraftPayloadSchema>;
36
+
37
+ export const getDraftPayloadSchema = z.object({
38
+ draftKey: draftKeySchema,
39
+ });
40
+ export type GetDraftPayload = z.infer<typeof getDraftPayloadSchema>;
41
+
42
+ // screenId is the draftKey's leading segment by convention (see lookup.ts),
43
+ // so it shares the same charset/length constraints as a full draftKey.
44
+ export const listDraftsPayloadSchema = z.object({
45
+ screenId: draftKeySchema,
46
+ });
47
+ export type ListDraftsPayload = z.infer<typeof listDraftsPayloadSchema>;
@@ -0,0 +1,146 @@
1
+ // formDraftExportHook — GDPR export must scope to the requesting user's own
2
+ // drafts only, not every draft in the tenant. formDraftDeleteHook physically
3
+ // deletes the requesting user's own draft rows and must leave other users'
4
+ // (and other tenants') rows untouched.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
+ import {
10
+ createTestUser,
11
+ setupTestStack,
12
+ type TestStack,
13
+ unsafeCreateEntityTable,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { createConfigFeature } from "../../config";
16
+ import { FormDraftHandlers, formDraftEntity, formDraftFeature } from "../../form-draft";
17
+ import { formDraftDeleteHook, formDraftExportHook } from "../hooks";
18
+
19
+ async function countDraftRows(stack: TestStack, ownerId: string): Promise<number> {
20
+ const rows = await asRawClient(stack.db).unsafe(
21
+ "SELECT count(*)::int AS n FROM read_form_drafts WHERE owner_id = $1",
22
+ [ownerId],
23
+ );
24
+ return (rows as Array<{ n: number }>)[0]?.n ?? 0;
25
+ }
26
+
27
+ let stack: TestStack;
28
+ const owner = createTestUser({ id: 1, roles: ["TenantMember"] });
29
+ const other = createTestUser({ id: 2, roles: ["TenantMember"] });
30
+
31
+ beforeAll(async () => {
32
+ stack = await setupTestStack({ features: [formDraftFeature, createConfigFeature()] });
33
+ await unsafeCreateEntityTable(stack.db, formDraftEntity);
34
+ await createEventsTable(stack.db);
35
+ });
36
+
37
+ afterAll(async () => {
38
+ await stack.cleanup();
39
+ });
40
+
41
+ describe("formDraftExportHook", () => {
42
+ test("includes only the requesting user's own drafts", async () => {
43
+ await stack.http.writeOk(
44
+ FormDraftHandlers.save,
45
+ { draftKey: "wizard:owner", values: { name: "owner's" }, stepIndex: 0 },
46
+ owner,
47
+ );
48
+ await stack.http.writeOk(
49
+ FormDraftHandlers.save,
50
+ { draftKey: "wizard:other", values: { name: "other's" }, stepIndex: 0 },
51
+ other,
52
+ );
53
+
54
+ const snippet = await formDraftExportHook({
55
+ db: stack.db,
56
+ registry: stack.registry,
57
+ tenantId: owner.tenantId,
58
+ userId: owner.id,
59
+ });
60
+
61
+ expect(snippet).not.toBeNull();
62
+ const draftKeys = (snippet?.rows ?? []).map((r) => r["draftKey"]);
63
+ expect(draftKeys).toEqual(["wizard:owner"]);
64
+ });
65
+
66
+ test("same user id in a different tenant does not leak into this tenant's export", async () => {
67
+ const tenantATestTenantId = "00000000-0000-4000-8000-000000000021";
68
+ const tenantBTestTenantId = "00000000-0000-4000-8000-000000000022";
69
+ const sameIdTenantA = createTestUser({
70
+ id: 21,
71
+ roles: ["TenantMember"],
72
+ tenantId: tenantATestTenantId,
73
+ });
74
+ const sameIdTenantB = createTestUser({
75
+ id: 21,
76
+ roles: ["TenantMember"],
77
+ tenantId: tenantBTestTenantId,
78
+ });
79
+ await stack.http.writeOk(
80
+ FormDraftHandlers.save,
81
+ { draftKey: "wizard:tenant-a", values: { secret: "tenant-a-only" }, stepIndex: 0 },
82
+ sameIdTenantA,
83
+ );
84
+ await stack.http.writeOk(
85
+ FormDraftHandlers.save,
86
+ { draftKey: "wizard:tenant-b", values: { secret: "tenant-b-only" }, stepIndex: 0 },
87
+ sameIdTenantB,
88
+ );
89
+
90
+ const snippet = await formDraftExportHook({
91
+ db: stack.db,
92
+ registry: stack.registry,
93
+ tenantId: tenantATestTenantId,
94
+ userId: sameIdTenantA.id,
95
+ });
96
+
97
+ const draftKeys = (snippet?.rows ?? []).map((r) => r["draftKey"]);
98
+ expect(draftKeys).toEqual(["wizard:tenant-a"]);
99
+ });
100
+
101
+ test("returns null when the user saved no drafts", async () => {
102
+ const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
103
+ const snippet = await formDraftExportHook({
104
+ db: stack.db,
105
+ registry: stack.registry,
106
+ tenantId: lurker.tenantId,
107
+ userId: lurker.id,
108
+ });
109
+ expect(snippet).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("formDraftDeleteHook", () => {
114
+ test("deletes the requesting user's own draft rows, leaves other users' rows alone", async () => {
115
+ const alice = createTestUser({ id: 11, roles: ["TenantMember"] });
116
+ const bob = createTestUser({ id: 12, roles: ["TenantMember"], tenantId: alice.tenantId });
117
+ await stack.http.writeOk(
118
+ FormDraftHandlers.save,
119
+ { draftKey: "wizard:a", values: {}, stepIndex: 0 },
120
+ alice,
121
+ );
122
+ await stack.http.writeOk(
123
+ FormDraftHandlers.save,
124
+ { draftKey: "wizard:b", values: {}, stepIndex: 0 },
125
+ bob,
126
+ );
127
+
128
+ await formDraftDeleteHook(
129
+ { db: stack.db, registry: stack.registry, tenantId: alice.tenantId, userId: alice.id },
130
+ "delete",
131
+ );
132
+
133
+ expect(await countDraftRows(stack, String(alice.id))).toBe(0);
134
+ expect(await countDraftRows(stack, String(bob.id))).toBe(1);
135
+ });
136
+
137
+ test("is idempotent — running it again with nothing left to delete does not throw", async () => {
138
+ const lurker = createTestUser({ id: 13, roles: ["TenantMember"] });
139
+ await expect(
140
+ formDraftDeleteHook(
141
+ { db: stack.db, registry: stack.registry, tenantId: lurker.tenantId, userId: lurker.id },
142
+ "delete",
143
+ ),
144
+ ).resolves.toBeUndefined();
145
+ });
146
+ });
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,49 @@
1
+ // EXT_USER_DATA hooks for the form-draft feature's `form-draft` entity
2
+ // (GDPR Art. 20 export / Art. 17 erasure). Lives apart from form-draft so
3
+ // consumers without the user-data-rights pipeline don't pull a hard
4
+ // dependency.
5
+
6
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
+ import {
9
+ createSystemUser,
10
+ type UserDataDeleteHook,
11
+ type UserDataExportHook,
12
+ } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { formDraftExecutor, formDraftTable } from "../form-draft";
14
+
15
+ export const formDraftExportHook: UserDataExportHook = async (ctx) => {
16
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, formDraftTable, {
17
+ tenantId: ctx.tenantId,
18
+ ownerId: ctx.userId,
19
+ });
20
+ if (rows.length === 0) return null;
21
+ return {
22
+ entity: "form-draft",
23
+ rows: rows.map((r) => ({
24
+ draftKey: r["draftKey"],
25
+ draft: r["draft"],
26
+ insertedAt: r["insertedAt"],
27
+ })),
28
+ };
29
+ };
30
+
31
+ // Real physical delete, not crypto-shredding — `draft` is jsonb (entity.ts
32
+ // explains why it can't use the string-only PII field-encryption engine).
33
+ // A drafted form is pure pre-submit scratch state with no value once the
34
+ // owner is gone, so both forget strategies (delete/anonymize) remove the
35
+ // row outright rather than anonymizing in place. Per-row via the executor
36
+ // (event -> rebuild-safe), scoped to this user only — never touches other
37
+ // owners' drafts, tenant model doesn't matter here (unlike folders' single-
38
+ // user gating: a form-draft row always has exactly one owner already).
39
+ export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
40
+ const rows = await selectMany<{ id: string }>(ctx.db, formDraftTable, {
41
+ tenantId: ctx.tenantId,
42
+ ownerId: ctx.userId,
43
+ });
44
+ const systemUser = createSystemUser(ctx.tenantId);
45
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
46
+ for (const row of rows) {
47
+ await formDraftExecutor.delete({ id: row.id }, systemUser, tdb);
48
+ }
49
+ };
@@ -0,0 +1,21 @@
1
+ // Provides the EXT_USER_DATA export/delete hooks for the form-draft
2
+ // feature's `form-draft` entity as a standalone feature — mount it alongside
3
+ // form-draft + user-data-rights when an app needs form drafts in its GDPR
4
+ // export/forget pipeline. Kept separate from form-draft (which requires
5
+ // nothing) so form-draft consumers without the user-data-rights stack don't
6
+ // pull a hard dependency. Mirrors notes-history-user-data.
7
+
8
+ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { formDraftDeleteHook, formDraftExportHook } from "./hooks";
10
+
11
+ export const formDraftUserDataFeature = defineFeature("form-draft-user-data", (r) => {
12
+ r.describe(
13
+ "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `form-draft` feature's `form-draft` entity. Mounts the EXT_USER_DATA export hook so a user's saved drafts are included in the user-data export bundle, and a delete hook that physically removes the user's own draft rows on forget. Kept separate from `form-draft` so form-draft consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `form-draft`.",
14
+ );
15
+ r.requires("user-data-rights");
16
+ r.optionalRequires("form-draft");
17
+ r.useExtension(EXT_USER_DATA, "form-draft", {
18
+ export: formDraftExportHook,
19
+ delete: formDraftDeleteHook,
20
+ });
21
+ });
@@ -624,7 +624,7 @@ describe("scenario 6: crypto-shredding (#800)", () => {
624
624
  inboundMessageAggregateId(accountId, "uid-shred"),
625
625
  admin.tenantId,
626
626
  );
627
- const ciphertext = (events[0]?.payload as { from?: string }).from as string;
627
+ const ciphertext = (events[0]!.payload as { from?: string }).from as string;
628
628
  expect(isPiiCiphertext(ciphertext)).toBe(true);
629
629
 
630
630
  const kms = configuredPiiSubjectKms();
@@ -26,6 +26,8 @@ function projectionProbeTable(name: string): SchemaTable {
26
26
  }) as unknown as SchemaTable; // @cast-boundary test-fixture — minimal probe shape, real schema-table irrelevant here
27
27
  }
28
28
 
29
+ import { formDraftFeature } from "../../form-draft";
30
+ import { formDraftUserDataFeature } from "../../form-draft-user-data";
29
31
  import { createUserFeature } from "../../user/feature";
30
32
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
31
33
  import { createUserDataRightsFeature } from "../feature";
@@ -72,6 +74,18 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
72
74
  expect(() => validateBoot(baseFeatures())).not.toThrow();
73
75
  });
74
76
 
77
+ test("form-draft + form-draft-user-data mounted together satisfies V2 and V3 (real feature pair, not synthetic)", () => {
78
+ expect(() =>
79
+ validateBoot([...baseFeatures(), formDraftFeature, formDraftUserDataFeature]),
80
+ ).not.toThrow();
81
+ });
82
+
83
+ test("form-draft mounted WITHOUT form-draft-user-data → V3 throws (userOwned draft field has no hook)", () => {
84
+ expect(() => validateBoot([...baseFeatures(), formDraftFeature])).toThrow(
85
+ /EXT_USER_DATA hook.*Art\.17 gap/,
86
+ );
87
+ });
88
+
75
89
  test("V2: export hook without delete hook → Art.17 throw", () => {
76
90
  const bad = defineFeature("bad-export", (r) => {
77
91
  r.requires("user-data-rights");