@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.
- package/package.json +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +112 -1
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +8 -2
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +76 -1
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +81 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +86 -0
- package/src/folders/web/folder-manager.tsx +27 -15
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +10 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +22 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +45 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +29 -8
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/__tests__/user.integration.test.ts +36 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +44 -2
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +2 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +69 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +42 -0
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +16 -2
- package/src/user-data-rights/screens.ts +7 -6
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +40 -5
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- 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 {
|
|
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(
|
|
16
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
// OPTIONAL:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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", {
|
package/src/jobs/feature.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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>>(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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({
|
|
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, {
|
|
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
|
|
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);
|
package/src/ledger/entity.ts
CHANGED
|
@@ -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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
|
69
|
+
const candidates = await selectMany(ctx.db.raw, transactionTable, {
|
|
61
70
|
tenantId: event.user.tenantId,
|
|
71
|
+
reference,
|
|
62
72
|
});
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
{
|
package/src/ledger/recurring.ts
CHANGED
|
@@ -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
|
|
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) => {
|
package/src/ledger/schemas.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/ledger/web/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Client-safe ledger surface: QN constants
|
|
3
|
-
// (projection + Soll/Ist merge),
|
|
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
|
-
//
|
|
6
|
-
//
|
|
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", () => {
|