@cosmicdrift/kumiko-bundled-features 0.105.1 → 0.108.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 (124) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +112 -1
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +8 -2
  22. package/src/custom-fields/wire-for-entity.ts +104 -77
  23. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  24. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +76 -1
  25. package/src/data-retention/resolve-for-tenant.ts +16 -4
  26. package/src/data-retention/run-retention-cleanup.ts +72 -10
  27. package/src/delivery/attempt-log.ts +22 -10
  28. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  29. package/src/folders/__tests__/folders.integration.test.ts +81 -0
  30. package/src/folders/feature.ts +4 -2
  31. package/src/folders/handlers/delete-folder.write.ts +40 -0
  32. package/src/folders/web/__tests__/folder-manager.test.tsx +86 -0
  33. package/src/folders/web/folder-manager.tsx +27 -15
  34. package/src/folders/web/i18n.ts +0 -2
  35. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  36. package/src/folders-user-data/hooks.ts +31 -8
  37. package/src/folders-user-data/index.ts +4 -4
  38. package/src/jobs/feature.ts +23 -21
  39. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  40. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  41. package/src/ledger/__tests__/reports.test.ts +20 -0
  42. package/src/ledger/entity.ts +1 -1
  43. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  44. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  45. package/src/ledger/recurring.ts +18 -9
  46. package/src/ledger/schemas.ts +4 -1
  47. package/src/ledger/web/index.ts +3 -2
  48. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  49. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +10 -0
  50. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
  51. package/src/personal-access-tokens/constants.ts +33 -0
  52. package/src/personal-access-tokens/feature.ts +78 -0
  53. package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
  54. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  55. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  56. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  57. package/src/personal-access-tokens/hash.ts +17 -0
  58. package/src/personal-access-tokens/index.ts +45 -0
  59. package/src/personal-access-tokens/resolver.ts +58 -0
  60. package/src/personal-access-tokens/roles.ts +29 -0
  61. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  62. package/src/personal-access-tokens/scopes.ts +22 -0
  63. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  64. package/src/personal-access-tokens/web/i18n.ts +45 -0
  65. package/src/personal-access-tokens/web/index.ts +7 -0
  66. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
  67. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  68. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  69. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  70. package/src/sessions/__tests__/sessions.integration.test.ts +29 -8
  71. package/src/sessions/schema/user-session.ts +8 -2
  72. package/src/sessions/session-callbacks.ts +9 -3
  73. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  74. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  75. package/src/tags/handlers/assign-tag.write.ts +13 -3
  76. package/src/tags/schemas.ts +10 -4
  77. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  78. package/src/template-resolver/testing.ts +4 -3
  79. package/src/tier-engine/feature.ts +22 -10
  80. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  81. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  82. package/src/user/__tests__/user.integration.test.ts +36 -0
  83. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  84. package/src/user/index.ts +4 -0
  85. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  86. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  87. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  88. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  89. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  90. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  91. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +44 -2
  92. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  93. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  94. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  95. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +2 -2
  96. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  97. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +69 -6
  98. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  99. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  100. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  101. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  102. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  103. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  104. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  105. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  106. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +42 -0
  107. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  108. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  109. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  110. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  111. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  112. package/src/user-data-rights/run-forget-cleanup.ts +16 -2
  113. package/src/user-data-rights/screens.ts +7 -6
  114. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +40 -5
  115. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  116. package/src/user-data-rights/web/i18n.ts +0 -10
  117. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  118. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  119. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  120. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  121. package/src/user-profile/handlers/change-email.write.ts +11 -8
  122. package/src/user-profile/web/profile-screen.tsx +10 -8
  123. package/src/auth-email-password/stream-tenant.ts +0 -31
  124. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -0,0 +1,69 @@
1
+ // folderAssignmentExportHook — GDPR export must not surface cleared
2
+ // (soft-deleted) folder-assignment rows. Regression (658/3): the hook read
3
+ // via selectMany without an isDeleted filter — folderAssignmentEntity is
4
+ // softDelete: true, so a cleared assignment (clear-folder) still had a row
5
+ // in read_folder_assignments and rode along in the export.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
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 { createFoldersFeature, FoldersHandlers } from "../../folders";
16
+ import { folderAssignmentEntity, folderEntity } from "../../folders/entity";
17
+ import { folderAssignmentExportHook } from "../hooks";
18
+
19
+ let stack: TestStack;
20
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
21
+
22
+ beforeAll(async () => {
23
+ stack = await setupTestStack({ features: [createFoldersFeature()] });
24
+ await unsafeCreateEntityTable(stack.db, folderEntity);
25
+ await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
26
+ await createEventsTable(stack.db);
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await stack.cleanup();
31
+ });
32
+
33
+ describe("folderAssignmentExportHook", () => {
34
+ test("excludes a cleared (soft-deleted) assignment from the export", async () => {
35
+ const folder = await stack.http.writeOk<{ id: string }>(
36
+ FoldersHandlers.createFolder,
37
+ { name: "Active loans" },
38
+ admin,
39
+ );
40
+
41
+ await stack.http.writeOk(
42
+ FoldersHandlers.setFolder,
43
+ { folderId: folder.id, entityType: "credit", entityId: "credit-kept" },
44
+ admin,
45
+ );
46
+ await stack.http.writeOk(
47
+ FoldersHandlers.setFolder,
48
+ { folderId: folder.id, entityType: "credit", entityId: "credit-cleared" },
49
+ admin,
50
+ );
51
+ // clear-folder soft-deletes the assignment row (isDeleted: true).
52
+ await stack.http.writeOk(
53
+ FoldersHandlers.clearFolder,
54
+ { entityType: "credit", entityId: "credit-cleared" },
55
+ admin,
56
+ );
57
+
58
+ const snippet = await folderAssignmentExportHook({
59
+ db: stack.db,
60
+ tenantId: admin.tenantId,
61
+ userId: admin.id,
62
+ });
63
+
64
+ expect(snippet).not.toBeNull();
65
+ const entityIds = (snippet?.rows ?? []).map((r) => r["entityId"]);
66
+ expect(entityIds).toContain("credit-kept");
67
+ expect(entityIds).not.toContain("credit-cleared");
68
+ });
69
+ });
@@ -4,16 +4,21 @@
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
6
 
7
- import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
8
9
  import {
9
10
  createEntityExecutor,
11
+ createSystemUser,
10
12
  type UserDataDeleteHook,
11
13
  type UserDataExportHook,
12
14
  } from "@cosmicdrift/kumiko-framework/engine";
13
15
  import { folderAssignmentEntity, folderEntity } from "../folders";
14
16
 
15
- const { table: folderTable } = createEntityExecutor("folder", folderEntity);
16
- const { table: folderAssignmentTable } = createEntityExecutor(
17
+ const { table: folderTable, executor: folderExecutor } = createEntityExecutor(
18
+ "folder",
19
+ folderEntity,
20
+ );
21
+ const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = createEntityExecutor(
17
22
  "folder-assignment",
18
23
  folderAssignmentEntity,
19
24
  );
@@ -30,8 +35,12 @@ export const folderExportHook: UserDataExportHook = async (ctx) => {
30
35
  };
31
36
 
32
37
  export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
38
+ // folderAssignmentEntity is softDelete: true — a cleared assignment
39
+ // (isDeleted: true) is a removed folder membership, not something the GDPR
40
+ // export should still surface as current data.
33
41
  const rows = await selectMany<Record<string, unknown>>(ctx.db, folderAssignmentTable, {
34
42
  tenantId: ctx.tenantId,
43
+ isDeleted: false,
35
44
  });
36
45
  if (rows.length === 0) return null;
37
46
  return { entity: "folder-assignment", rows };
@@ -43,16 +52,30 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
43
52
  // tenant would destroy co-members' folders. anonymize is also a no-op — folder
44
53
  // rows carry no person-link to strip (name is tenant data, not PII), so a
45
54
  // retention hold simply keeps them.
46
- function tenantScopedDelete(table: typeof folderTable): UserDataDeleteHook {
55
+ function tenantScopedDelete(
56
+ table: typeof folderTable,
57
+ executor: EventStoreExecutor,
58
+ ): UserDataDeleteHook {
47
59
  return async (ctx, strategy) => {
48
60
  // skip: multi-user tenant — a tenant-wide delete would destroy co-members' folders
49
61
  if (ctx.tenantModel !== "single-user") return;
50
62
  // skip: anonymize is a no-op — folder rows carry no per-user PII to strip
51
63
  if (strategy === "anonymize") return;
52
- await deleteMany(ctx.db, table, { tenantId: ctx.tenantId });
64
+ // Per-row via the executor (event -> rebuild-safe): a bulk deleteMany is
65
+ // eventless, so a projection rebuild resurrects the rows. Bounded — forget
66
+ // only fires for single-user tenants.
67
+ const systemUser = createSystemUser(ctx.tenantId);
68
+ // The executor needs a TenantDb (loadById → db.fetchOne), not the raw ctx.db.
69
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
70
+ const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
71
+ for (const row of rows) {
72
+ await executor.delete({ id: row.id }, systemUser, tdb);
73
+ }
53
74
  };
54
75
  }
55
76
 
56
- export const folderDeleteHook: UserDataDeleteHook = tenantScopedDelete(folderTable);
57
- export const folderAssignmentDeleteHook: UserDataDeleteHook =
58
- tenantScopedDelete(folderAssignmentTable);
77
+ export const folderDeleteHook: UserDataDeleteHook = tenantScopedDelete(folderTable, folderExecutor);
78
+ export const folderAssignmentDeleteHook: UserDataDeleteHook = tenantScopedDelete(
79
+ folderAssignmentTable,
80
+ folderAssignmentExecutor,
81
+ );
@@ -16,10 +16,10 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
16
16
  r.describe(
17
17
  "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `folders` feature's `folder` + `folder-assignment` entities. Mounts the EXT_USER_DATA export + delete hooks so a tenant's folder tree and its entity-to-folder assignments are included in the user-data export bundle and erased on a tenant-scoped forget (single-user tenants only; multi-user + anonymize are no-ops since folder rows carry no per-user PII). Kept separate from `folders` so folder consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `folders`.",
18
18
  );
19
- // user-data-rights ist die harte Abhängigkeit (EXT_USER_DATA-Host). `folders` ist
20
- // OPTIONAL: ist es toggleable(default=false) gemountet (z.B. per-Tenant via Tier),
21
- // würde ein hartes r.requires eine effectively disabled"-Boot-Warnung werfen,
22
- // obwohl die folder-Entities existieren und die Hooks korrekt greifen.
19
+ // user-data-rights is the hard dependency (EXT_USER_DATA host). `folders` is
20
+ // OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
21
+ // tier), a hard r.requires would throw an "effectively disabled" boot
22
+ // warning even though the folder entities exist and the hooks work fine.
23
23
  r.requires("user-data-rights");
24
24
  r.optionalRequires("folders");
25
25
  r.useExtension(EXT_USER_DATA, "folder", {
@@ -57,9 +57,9 @@ export function createJobsFeature(): FeatureDefinition {
57
57
  table: jobRunsTable,
58
58
  apply: {
59
59
  [JOB_RUN_STARTED_EVENT]: defineApply<z.infer<typeof runStartedSchema>>(
60
- async (event, tx) => {
60
+ async (event, tx, table) => {
61
61
  const p = event.payload;
62
- await insertOne(tx, jobRunsTable, {
62
+ await insertOne(tx, table, {
63
63
  id: event.aggregateId,
64
64
  tenantId: event.tenantId,
65
65
  version: event.version,
@@ -76,11 +76,11 @@ export function createJobsFeature(): FeatureDefinition {
76
76
  },
77
77
  ),
78
78
  [JOB_RUN_COMPLETED_EVENT]: defineApply<z.infer<typeof runCompletedSchema>>(
79
- async (event, tx) => {
79
+ async (event, tx, table) => {
80
80
  const p = event.payload;
81
81
  await updateMany(
82
82
  tx,
83
- jobRunsTable,
83
+ table,
84
84
  {
85
85
  status: "completed",
86
86
  duration: p.duration,
@@ -93,23 +93,25 @@ export function createJobsFeature(): FeatureDefinition {
93
93
  );
94
94
  },
95
95
  ),
96
- [JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(async (event, tx) => {
97
- const p = event.payload;
98
- await updateMany(
99
- tx,
100
- jobRunsTable,
101
- {
102
- status: "failed",
103
- error: p.error,
104
- duration: p.duration,
105
- finishedAt: Temporal.Instant.from(p.finishedAt),
106
- version: event.version,
107
- modifiedAt: event.createdAt,
108
- modifiedById: event.metadata?.userId ?? "system",
109
- },
110
- { id: event.aggregateId },
111
- );
112
- }),
96
+ [JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(
97
+ async (event, tx, table) => {
98
+ const p = event.payload;
99
+ await updateMany(
100
+ tx,
101
+ table,
102
+ {
103
+ status: "failed",
104
+ error: p.error,
105
+ duration: p.duration,
106
+ finishedAt: Temporal.Instant.from(p.finishedAt),
107
+ version: event.version,
108
+ modifiedAt: event.createdAt,
109
+ modifiedById: event.metadata?.userId ?? "system",
110
+ },
111
+ { id: event.aggregateId },
112
+ );
113
+ },
114
+ ),
113
115
  },
114
116
  });
115
117
 
@@ -6,10 +6,15 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
7
7
  import { z } from "zod";
8
8
 
9
- export const projectionRebuildPayloadSchema = z.object({ projection: z.string().min(1) });
9
+ export const projectionRebuildPayloadSchema = z.object({
10
+ projection: z.string().min(1),
11
+ // Quarantine mode (#760): skip + dead-letter poison events instead of
12
+ // failing the whole rebuild. Operator opt-in per run.
13
+ skipApplyErrors: z.boolean().optional(),
14
+ });
10
15
 
11
16
  export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
12
- const { projection } = projectionRebuildPayloadSchema.parse(rawPayload);
17
+ const { projection, skipApplyErrors } = projectionRebuildPayloadSchema.parse(rawPayload);
13
18
  if (!ctx.db) {
14
19
  throw new InternalError({
15
20
  message:
@@ -23,8 +28,13 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
23
28
  });
24
29
  }
25
30
  const db = ctx.db as DbConnection; // @cast-boundary db-operator
26
- const result = await rebuildProjection(projection, { db, registry: ctx.registry });
31
+ const result = await rebuildProjection(projection, {
32
+ db,
33
+ registry: ctx.registry,
34
+ ...(skipApplyErrors === true && { errorPolicy: { skipApplyErrors: true } }),
35
+ });
27
36
  ctx.log?.info?.(
28
- `[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events in ${result.durationMs}ms`,
37
+ `[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events` +
38
+ `${result.eventsSkipped > 0 ? ` (${result.eventsSkipped} quarantined)` : ""} in ${result.durationMs}ms`,
29
39
  );
30
40
  };
@@ -188,6 +188,50 @@ describe("ledger integration — reverse-transaction (Storno)", () => {
188
188
  // Original + Storno cancel → books net to zero.
189
189
  expect(trialBalance(rows)).toBe(0);
190
190
  });
191
+
192
+ test("reversing a draft transaction is rejected (only posted entries count)", async () => {
193
+ const bank = await createAccount("Bank Draft", "asset");
194
+ const rent = await createAccount("Draft Income", "income");
195
+ const draft = await stack.http.writeOk<{ id: string }>(
196
+ LedgerHandlers.createTransaction,
197
+ {
198
+ date: "2026-01-15",
199
+ description: "Draft entry",
200
+ status: "draft",
201
+ lines: [
202
+ { accountId: bank, amount: 5000 },
203
+ { accountId: rent, amount: -5000 },
204
+ ],
205
+ },
206
+ admin,
207
+ );
208
+
209
+ const err = await stack.http.writeErr(
210
+ LedgerHandlers.reverseTransaction,
211
+ { id: draft.id },
212
+ admin,
213
+ );
214
+ expect(err.code).toBe("unprocessable");
215
+
216
+ // No Storno booked — the draft is the only row.
217
+ expect(await listTransactions()).toHaveLength(1);
218
+ });
219
+
220
+ test("reversing the same transaction twice is rejected on the second call", async () => {
221
+ const bank = await createAccount("Bank Dup", "asset");
222
+ const rent = await createAccount("Dup Income", "income");
223
+ const tx = await createTransaction([
224
+ { accountId: bank, amount: 20000 },
225
+ { accountId: rent, amount: -20000 },
226
+ ]);
227
+
228
+ await stack.http.writeOk(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
229
+ const err = await stack.http.writeErr(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
230
+ expect(err.code).toBe("conflict");
231
+
232
+ // Original + exactly ONE Storno — a second reverse must not book again.
233
+ expect(await listTransactions()).toHaveLength(2);
234
+ });
191
235
  });
192
236
 
193
237
  describe("ledger integration — trial balance (golden invariant)", () => {
@@ -5,6 +5,7 @@ import {
5
5
  incomeStatement,
6
6
  type LedgerAccount,
7
7
  type LedgerEntry,
8
+ normalizeLines,
8
9
  } from "../reports";
9
10
 
10
11
  // Pure report math — no DB. A small but complete set of books:
@@ -50,6 +51,25 @@ function balanceOf(report: ReturnType<typeof accountBalances>, id: string): numb
50
51
  return report.accounts.find((a) => a.id === id)?.balance ?? Number.NaN;
51
52
  }
52
53
 
54
+ describe("normalizeLines — jsonb array-or-string driver surfacing", () => {
55
+ test("passes an already-parsed array through unchanged", () => {
56
+ const lines = [{ accountId: "bank", amount: 100 }];
57
+ expect(normalizeLines(lines)).toEqual(lines);
58
+ });
59
+
60
+ test("parses a JSON-string-encoded array (the other driver path)", () => {
61
+ const lines = [{ accountId: "bank", amount: 100 }];
62
+ expect(normalizeLines(JSON.stringify(lines))).toEqual(lines);
63
+ });
64
+
65
+ test("falls back to [] for malformed JSON or a non-array value", () => {
66
+ expect(normalizeLines("not json")).toEqual([]);
67
+ expect(normalizeLines(null)).toEqual([]);
68
+ expect(normalizeLines(undefined)).toEqual([]);
69
+ expect(normalizeLines(42)).toEqual([]);
70
+ });
71
+ });
72
+
53
73
  describe("accountBalances — natural balances + trial balance", () => {
54
74
  test("natural balances are sign-corrected by account type", () => {
55
75
  const r = accountBalances(accounts, posted);
@@ -73,7 +73,7 @@ export const scheduleEntity = createEntity({
73
73
  // Absent → open-ended (projects to the window's end).
74
74
  endDate: createDateField(),
75
75
  interval: createSelectField({ options: SCHEDULE_INTERVALS, required: true }),
76
- amount: createNumberField({ required: true }),
76
+ amount: createNumberField({ required: true, min: 1, integer: true }),
77
77
  debitAccountId: createTextField({ required: true, maxLength: 64 }),
78
78
  creditAccountId: createTextField({ required: true, maxLength: 64 }),
79
79
  },
@@ -13,7 +13,7 @@ import {
13
13
  transactionExecutor,
14
14
  transactionTable,
15
15
  } from "../executor";
16
- import { scheduleReference } from "../recurring";
16
+ import { findReversedIds, isoMonth, scheduleReference } from "../recurring";
17
17
  import { type ConfirmSchedulePeriodPayload, confirmSchedulePeriodPayloadSchema } from "../schemas";
18
18
 
19
19
  // confirm-schedule-period — turn ONE projected period of a schedule into a posted,
@@ -42,6 +42,13 @@ export function createConfirmSchedulePeriodHandler(
42
42
  );
43
43
  if (!schedule) return writeFailure(new NotFoundError("schedule", payload.scheduleId));
44
44
 
45
+ if (
46
+ payload.period < isoMonth(String(schedule["startDate"])) ||
47
+ (schedule["endDate"] != null && payload.period > isoMonth(String(schedule["endDate"])))
48
+ ) {
49
+ return writeFailure(new NotFoundError("schedule-period", payload.period));
50
+ }
51
+
45
52
  const debitAccountId = String(schedule["debitAccountId"]);
46
53
  const creditAccountId = String(schedule["creditAccountId"]);
47
54
  for (const accountId of [debitAccountId, creditAccountId]) {
@@ -51,24 +58,33 @@ export function createConfirmSchedulePeriodHandler(
51
58
 
52
59
  const reference = scheduleReference(payload.scheduleId, payload.period);
53
60
 
54
- // Scan this tenant's transactions (same full-tenant read the reports do) to
55
- // find an active booking for this reference. A tx is reversed when another
56
- // tx's reference names its id (the Storno mirror).
61
+ // Two targeted reads instead of a full-tenant transaction scan: (1) any
62
+ // booking that already carries this exact schedule-period reference
63
+ // normally 0 or 1 row, never O(tenant's-full-history); (2) any Storno
64
+ // that mirrors one of those candidates (a tx is reversed when ANOTHER
65
+ // tx's `reference` names its id).
57
66
  // ponytail: read-then-write, so two confirms racing the same period could
58
67
  // double-book; add a unique index on (tenantId, reference) when concurrent
59
68
  // confirms become real.
60
- const txRows = await selectMany(ctx.db.raw, transactionTable, {
69
+ const candidates = await selectMany(ctx.db.raw, transactionTable, {
61
70
  tenantId: event.user.tenantId,
71
+ reference,
62
72
  });
63
- const txIds = new Set(txRows.map((r) => String(r["id"])));
64
- const reversedTxIds = new Set(
65
- txRows
66
- .filter((r) => r["reference"] != null && txIds.has(String(r["reference"])))
67
- .map((r) => String(r["reference"])),
68
- );
69
- const active = txRows.find(
70
- (r) => r["reference"] === reference && !reversedTxIds.has(String(r["id"])),
73
+ const candidateIds = candidates.map((r) => String(r["id"]));
74
+ const stornos =
75
+ candidateIds.length > 0
76
+ ? await selectMany(ctx.db.raw, transactionTable, {
77
+ tenantId: event.user.tenantId,
78
+ reference: { in: candidateIds },
79
+ })
80
+ : [];
81
+ const reversedIds = findReversedIds(
82
+ [...candidates, ...stornos].map((r) => ({
83
+ id: String(r["id"]),
84
+ reference: r["reference"] === null ? null : String(r["reference"]),
85
+ })),
71
86
  );
87
+ const active = candidates.find((r) => !reversedIds.has(String(r["id"])));
72
88
  if (active) {
73
89
  const ok: WriteResult<{ id: string; alreadyBooked: true }> = {
74
90
  isSuccess: true,
@@ -1,9 +1,15 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import {
4
+ ConflictError,
5
+ NotFoundError,
6
+ UnprocessableError,
7
+ writeFailure,
8
+ } from "@cosmicdrift/kumiko-framework/errors";
3
9
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
10
  import { DEFAULT_LEDGER_ACCESS } from "../constants";
5
- import { transactionExecutor } from "../executor";
6
- import type { Posting } from "../schemas";
11
+ import { transactionExecutor, transactionTable } from "../executor";
12
+ import { normalizeLines } from "../reports";
7
13
  import { type ReverseTransactionPayload, reverseTransactionPayloadSchema } from "../schemas";
8
14
 
9
15
  // reverse-transaction (Storno) — the ONLY correction path for a posted entry.
@@ -24,8 +30,41 @@ export function createReverseTransactionHandler(
24
30
  const original = await transactionExecutor.detail({ id: payload.id }, event.user, ctx.db);
25
31
  if (!original) return writeFailure(new NotFoundError("transaction", payload.id));
26
32
 
27
- const originalLines = original["lines"] as readonly Posting[]; // @cast-boundary db-row
28
- const lines = originalLines.map((l) => ({ accountId: l.accountId, amount: -l.amount }));
33
+ // Only a posted entry contributes to the books (rawBalances skips
34
+ // anything else) reversing a draft would book a real, balanced
35
+ // Storno entry against a booking that was never counted, creating a
36
+ // phantom balance with no corresponding original.
37
+ if (original["status"] !== "posted") {
38
+ return writeFailure(
39
+ new UnprocessableError("transaction_not_posted", {
40
+ details: { transactionId: payload.id, status: original["status"] },
41
+ }),
42
+ );
43
+ }
44
+
45
+ // Dedup guard: two reverse() calls on the same original would each book
46
+ // an independently-balanced Storno (the global trial balance stays 0
47
+ // either way, masking the bug), doubling the per-account effect. The
48
+ // reference column ties a Storno back to its original 1:1.
49
+ const alreadyReversed = await selectMany(ctx.db.raw, transactionTable, {
50
+ tenantId: event.user.tenantId,
51
+ reference: payload.id,
52
+ });
53
+ if (alreadyReversed.length > 0) {
54
+ return writeFailure(
55
+ new ConflictError({
56
+ message: "transaction already reversed",
57
+ details: { transactionId: payload.id },
58
+ }),
59
+ );
60
+ }
61
+
62
+ // jsonb `lines` may surface as a parsed array or a JSON string depending
63
+ // on the driver path — normalizeLines handles both (see reports.ts).
64
+ const lines = normalizeLines(original["lines"]).map((l) => ({
65
+ accountId: l.accountId,
66
+ amount: -l.amount,
67
+ }));
29
68
 
30
69
  return transactionExecutor.create(
31
70
  {
@@ -53,10 +53,26 @@ export function scheduleReference(scheduleId: string, period: string): string {
53
53
  return `schedule:${scheduleId}:${period}`;
54
54
  }
55
55
 
56
+ // A Storno mirror carries reference = the reversed tx's id, so any tx whose
57
+ // reference names another tx in the set marks that other tx reversed. Shared
58
+ // by mergeScheduleActuals (below) and confirm-schedule-period.write.ts's
59
+ // idempotency check (684/4) — same detection, kept in one place so a fix
60
+ // can't land in one call-site and drift from the other.
61
+ export function findReversedIds(
62
+ rows: readonly { readonly id: string; readonly reference: string | null }[],
63
+ ): Set<string> {
64
+ const ids = new Set(rows.map((r) => r.id));
65
+ return new Set(
66
+ rows
67
+ .filter((r) => r.reference !== null && ids.has(r.reference))
68
+ .map((r) => r.reference as string),
69
+ );
70
+ }
71
+
56
72
  // "YYYY-MM" ⇄ a month index (year*12 + monthOfYear), so window math is pure
57
73
  // integer arithmetic — no Date object (no-date-api guard) and no DST/timezone
58
74
  // drift. isoMonth tolerates a full ISO date ("2026-01-15" → "2026-01").
59
- function isoMonth(iso: string): string {
75
+ export function isoMonth(iso: string): string {
60
76
  return iso.slice(0, 7);
61
77
  }
62
78
 
@@ -105,14 +121,7 @@ export function mergeScheduleActuals(
105
121
  transactions: readonly LedgerTxRow[],
106
122
  asOf: string,
107
123
  ): ScheduleMonth[] {
108
- const txIds = new Set(transactions.map((t) => t.id));
109
- // A Storno mirror carries reference = the reversed tx's id, so any tx whose
110
- // reference names another tx marks that other tx reversed.
111
- const reversedTxIds = new Set(
112
- transactions
113
- .filter((t) => t.reference != null && txIds.has(t.reference))
114
- .map((t) => t.reference as string),
115
- );
124
+ const reversedTxIds = findReversedIds(transactions);
116
125
  const asOfIndex = monthIndex(isoMonth(asOf));
117
126
 
118
127
  return projection.map((projected) => {
@@ -57,7 +57,10 @@ export const confirmSchedulePeriodPayloadSchema = z.object({
57
57
  scheduleId: z.string().min(1).max(64),
58
58
  period: z.string().regex(/^\d{4}-\d{2}$/, "period must be YYYY-MM"),
59
59
  amount: z.number().int().positive().optional(),
60
- date: z.string().min(1).max(32).optional(),
60
+ date: z
61
+ .string()
62
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
63
+ .optional(),
61
64
  });
62
65
  export type ConfirmSchedulePeriodPayload = z.infer<typeof confirmSchedulePeriodPayloadSchema>;
63
66
 
@@ -1,6 +1,7 @@
1
1
  // @runtime client
2
- // Client-safe ledger surface: QN constants + the pure recurring helpers
3
- // (projection + Soll/Ist merge), nothing else. The full `../ledger` entry
2
+ // Client-safe ledger surface: QN constants, the pure recurring helpers
3
+ // (projection + Soll/Ist merge), and the Zod payload schemas (for client-side
4
+ // pre-validation before dispatch) — nothing else. The full `../ledger` entry
4
5
  // re-exports the feature/handlers/executor, which pull bun-db/postgres — a
5
6
  // browser bundle that imports from there fails on Node builtins. A client screen
6
7
  // (e.g. a rent-cashflow view) imports the dispatch QNs + the pure forecast/merge
@@ -2,12 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import { type BrandingTokens, EMPTY_BRANDING } from "../../page-render";
3
3
  import { coerceBranding } from "../branding";
4
4
 
5
- // coerceBranding is the IO boundary for the branding query's wire response:
6
- // untrusted `unknown` BrandingTokens with no `as` cast. Every missing or
7
- // non-string field must collapse to "" so a malformed/empty response renders
8
- // the unbranded default rather than throwing — and an attacker-controlled
9
- // non-string (e.g. a logoUrl object) can never leak through as a live render
10
- // token. Exercised only indirectly by the integration path before this.
5
+ // IO-boundary coercion: untrusted `unknown` BrandingTokens, no `as` cast
6
+ // every missing/non-string field collapses to "" instead of throwing.
11
7
 
12
8
  describe("coerceBranding", () => {
13
9
  const FULL: BrandingTokens = {
@@ -160,6 +160,16 @@ describe("managed-pages :: Cache + Security-Header", () => {
160
160
  });
161
161
  expect(second.status).toBe(304);
162
162
  });
163
+
164
+ test("HEAD → 200 without body, etag present", async () => {
165
+ // legal-pages got this HEAD-early-exit test alongside its identical
166
+ // Cache/Header pattern; managed-pages received the same HEAD codepath
167
+ // in the same PR but not the test.
168
+ const res = await stack.app.request("http://a.example.com/p/about", { method: "HEAD" });
169
+ expect(res.status).toBe(200);
170
+ expect(await res.text()).toBe("");
171
+ expect(res.headers.get("etag")).toBeTruthy();
172
+ });
163
173
  });
164
174
 
165
175
  describe("managed-pages :: XSS-Härtung", () => {