@cosmicdrift/kumiko-framework 0.364.0 → 0.365.1
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/AGENTS.md +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/webhook-step.md +11 -1
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +29 -4
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +7 -0
- package/dist/bun-db/query.js +50 -18
- package/dist/crypto/in-memory-kms-adapter.js +4 -7
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pg-kms-adapter.js +10 -6
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +85 -58
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +21 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +183 -48
- package/dist/jobs/max-per-tenant.d.ts +8 -0
- package/dist/jobs/max-per-tenant.js +14 -0
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
- package/dist/pipeline/multi-stream-apply-context.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +4 -4
- package/src/changes.json +323 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# pdf-audit-export
|
|
4
4
|
|
|
5
|
-
Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and
|
|
5
|
+
Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and uploads the bytes before the transaction (uploadFileBytesBeforeTransaction) and stores them as a FileRef (createFileRefFromUploadedBytes).
|
|
6
6
|
|
|
7
7
|
## README
|
|
8
8
|
|
|
@@ -13,7 +13,8 @@ A write handler exports tenant data as a PDF and keeps it as a FileRef.
|
|
|
13
13
|
## What it shows
|
|
14
14
|
|
|
15
15
|
- `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
|
|
16
|
-
- `
|
|
16
|
+
- The export is a `beforeTransaction` handler: it reads the rows (reads work in that phase), renders the PDF and calls `uploadFileBytesBeforeTransaction` (`@cosmicdrift/kumiko-framework/files`), which validates the bytes and writes them to the tenant-prefixed storage key. The upload runs before the transaction because storage I/O inside it would hold a pool connection for as long as the upload takes.
|
|
17
|
+
- The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; nothing sweeps them.
|
|
17
18
|
- The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
|
|
18
19
|
|
|
19
20
|
## Source
|
|
@@ -147,6 +148,21 @@ describe("export-audit", () => {
|
|
|
147
148
|
const { text } = await extractText(proxy, { mergePages: true });
|
|
148
149
|
expect(text).toContain("Keine Prüfungen im Zeitraum");
|
|
149
150
|
});
|
|
151
|
+
|
|
152
|
+
test("a period with more inspections than the export holds is rejected instead of cut off", async () => {
|
|
153
|
+
const assetName = "Großanlage";
|
|
154
|
+
for (let day = 0; day < 101; day += 1) {
|
|
155
|
+
const inspectedOn = new Date(Date.UTC(2025, 0, 1 + day)).toISOString().slice(0, 10);
|
|
156
|
+
await addInspection(inspectedOn, "ok", `Prüfer-${day}`, assetName);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const err = await stack.http.writeErr(
|
|
160
|
+
"pdf-audit-export:write:export-audit",
|
|
161
|
+
{ assetName, from: "2025-01-01", to: "2025-12-31" },
|
|
162
|
+
userA,
|
|
163
|
+
);
|
|
164
|
+
expect(err.httpStatus).toBe(422);
|
|
165
|
+
});
|
|
150
166
|
});
|
|
151
167
|
```
|
|
152
168
|
|
|
@@ -156,9 +172,10 @@ describe("export-audit", () => {
|
|
|
156
172
|
// PDF Audit Export Sample
|
|
157
173
|
//
|
|
158
174
|
// A write handler turns tenant data into a PDF and keeps it as a regular
|
|
159
|
-
// FileRef: renderTablePdf draws the table (pure JS, no browser)
|
|
160
|
-
//
|
|
161
|
-
//
|
|
175
|
+
// FileRef: renderTablePdf draws the table (pure JS, no browser). The storage
|
|
176
|
+
// write runs in the beforeTransaction phase, so the transaction holds no pool
|
|
177
|
+
// connection while the bytes upload; the handler then only creates the
|
|
178
|
+
// fileRef row from event.prepared.
|
|
162
179
|
|
|
163
180
|
import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
|
|
164
181
|
import {
|
|
@@ -168,8 +185,13 @@ import {
|
|
|
168
185
|
createSelectField,
|
|
169
186
|
createTextField,
|
|
170
187
|
defineFeature,
|
|
188
|
+
defineWriteHandler,
|
|
171
189
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
172
|
-
import {
|
|
190
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
191
|
+
import {
|
|
192
|
+
createFileRefFromUploadedBytes,
|
|
193
|
+
uploadFileBytesBeforeTransaction,
|
|
194
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
173
195
|
import * as z from "zod";
|
|
174
196
|
|
|
175
197
|
export const INSPECTION_RESULTS = ["ok", "defect"] as const;
|
|
@@ -199,7 +221,8 @@ function toFileNameSegment(text: string): string {
|
|
|
199
221
|
return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");
|
|
200
222
|
}
|
|
201
223
|
|
|
202
|
-
|
|
224
|
+
// One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
|
|
225
|
+
const AUDIT_ROW_LIMIT = 100;
|
|
203
226
|
|
|
204
227
|
const access = { access: { roles: ["Admin", "User"] } } as const;
|
|
205
228
|
|
|
@@ -220,51 +243,64 @@ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
|
|
|
220
243
|
);
|
|
221
244
|
|
|
222
245
|
r.writeHandler(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
{
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
246
|
+
defineWriteHandler({
|
|
247
|
+
name: "export-audit",
|
|
248
|
+
schema: z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
|
|
249
|
+
access: access.access,
|
|
250
|
+
beforeTransaction: async (event, ctx) => {
|
|
251
|
+
const { assetName, from, to } = event.payload;
|
|
252
|
+
const inspections = await inspectionExecutor.list(
|
|
253
|
+
{
|
|
254
|
+
filters: [
|
|
255
|
+
{ field: "assetName", op: "eq", value: assetName },
|
|
256
|
+
{ field: "inspectedOn", op: "gte", value: from },
|
|
257
|
+
{ field: "inspectedOn", op: "lte", value: to },
|
|
258
|
+
],
|
|
259
|
+
sort: "inspectedOn",
|
|
260
|
+
sortDirection: "asc",
|
|
261
|
+
limit: AUDIT_ROW_LIMIT + 1,
|
|
262
|
+
},
|
|
263
|
+
event.user,
|
|
264
|
+
ctx.db,
|
|
265
|
+
);
|
|
266
|
+
// A protocol that silently drops inspections would look complete.
|
|
267
|
+
if (inspections.rows.length > AUDIT_ROW_LIMIT) {
|
|
268
|
+
return writeFailure(
|
|
269
|
+
new UnprocessableError("audit_period_too_large", {
|
|
270
|
+
details: { limit: AUDIT_ROW_LIMIT },
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { pdfBytes } = await renderTablePdf({
|
|
276
|
+
title: `Prüfprotokoll ${assetName}`,
|
|
277
|
+
meta: [
|
|
278
|
+
{ label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
|
|
279
|
+
{ label: "Prüfungen", value: String(inspections.rows.length) },
|
|
233
280
|
],
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
const created = await createFileRefFromBytes(ctx, {
|
|
260
|
-
fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
|
|
261
|
-
mimeType: "application/pdf",
|
|
262
|
-
data: pdfBytes,
|
|
263
|
-
});
|
|
264
|
-
if (!created.isSuccess) return created;
|
|
265
|
-
return { isSuccess: true as const, data: { fileRefId: created.data.id } };
|
|
266
|
-
},
|
|
267
|
-
access,
|
|
281
|
+
columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
|
|
282
|
+
rows: inspections.rows.map((row) => [
|
|
283
|
+
formatGermanDate(String(row["inspectedOn"])),
|
|
284
|
+
RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
|
|
285
|
+
String(row["inspector"] ?? ""),
|
|
286
|
+
]),
|
|
287
|
+
emptyText: "Keine Prüfungen im Zeitraum",
|
|
288
|
+
footerText: `Prüfbericht ${assetName}`,
|
|
289
|
+
pageLabel: "Seite {page} von {pages}",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return uploadFileBytesBeforeTransaction(ctx, {
|
|
293
|
+
fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
|
|
294
|
+
mimeType: "application/pdf",
|
|
295
|
+
data: pdfBytes,
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
handler: async (event, ctx) => {
|
|
299
|
+
const created = await createFileRefFromUploadedBytes(ctx, event.prepared);
|
|
300
|
+
if (!created.isSuccess) return created;
|
|
301
|
+
return { isSuccess: true as const, data: { fileRefId: created.data.id } };
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
268
304
|
);
|
|
269
305
|
});
|
|
270
306
|
```
|
|
@@ -67,7 +67,7 @@ const server = buildServer({
|
|
|
67
67
|
// real HTTP + DB + Redis. If a reader copies the buildServer({...}) block
|
|
68
68
|
// verbatim, the resulting app behaves like this test asserts.
|
|
69
69
|
|
|
70
|
-
import { afterAll, beforeAll,
|
|
70
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
71
71
|
import {
|
|
72
72
|
AuthHandlers,
|
|
73
73
|
createAuthEmailPasswordFeature,
|
|
@@ -81,7 +81,6 @@ import {
|
|
|
81
81
|
import {
|
|
82
82
|
type SessionCallbacks,
|
|
83
83
|
userSessionEntity,
|
|
84
|
-
userSessionTable,
|
|
85
84
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
86
85
|
import {
|
|
87
86
|
sessionCallbacksFromLateBound,
|
|
@@ -97,9 +96,7 @@ import {
|
|
|
97
96
|
createUserFeature,
|
|
98
97
|
UserHandlers,
|
|
99
98
|
userEntity,
|
|
100
|
-
userTable,
|
|
101
99
|
} from "@cosmicdrift/kumiko-bundled-features/user";
|
|
102
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
103
100
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
104
101
|
import {
|
|
105
102
|
setupTestStack,
|
|
@@ -147,12 +144,6 @@ afterAll(async () => {
|
|
|
147
144
|
await stack.cleanup();
|
|
148
145
|
});
|
|
149
146
|
|
|
150
|
-
beforeEach(async () => {
|
|
151
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
|
|
152
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
153
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
147
|
describe("session-revocation sample wiring", () => {
|
|
157
148
|
test("end-to-end: login, use, logout, reject", async () => {
|
|
158
149
|
const hash = await hashPassword("super-long-password");
|
|
@@ -130,18 +130,20 @@ import {
|
|
|
130
130
|
tagAssignmentEntity,
|
|
131
131
|
tagEntity,
|
|
132
132
|
} from "@cosmicdrift/kumiko-bundled-features/tags";
|
|
133
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
134
133
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
135
134
|
import {
|
|
136
135
|
createTestUser,
|
|
137
136
|
setupTestStack,
|
|
138
137
|
type TestStack,
|
|
138
|
+
testTenantId,
|
|
139
139
|
unsafeCreateEntityTable,
|
|
140
140
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
141
141
|
import { noteEntity, noteFeature } from "../feature";
|
|
142
142
|
import { type TagClient, tagFlow } from "../usage";
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
// Each test runs in its own tenant, so rows from other tests never show up.
|
|
145
|
+
let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
|
|
146
|
+
let nextTenant = 1;
|
|
145
147
|
|
|
146
148
|
// Adapt the test stack to the host-facing TagClient the docs embed uses.
|
|
147
149
|
const tagClient: TagClient = {
|
|
@@ -164,11 +166,9 @@ afterAll(async () => {
|
|
|
164
166
|
await stack.cleanup();
|
|
165
167
|
});
|
|
166
168
|
|
|
167
|
-
beforeEach(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await asRawClient(stack.db).unsafe("DELETE FROM read_tags");
|
|
171
|
-
await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
nextTenant += 1;
|
|
171
|
+
admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
async function createNote(id: string, title: string) {
|
|
@@ -144,7 +144,7 @@ import {
|
|
|
144
144
|
runForgetCleanup,
|
|
145
145
|
runUserExport,
|
|
146
146
|
} from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
|
|
147
|
-
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
147
|
+
import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
148
148
|
import { EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
149
149
|
import {
|
|
150
150
|
setupTestStack,
|
|
@@ -157,9 +157,13 @@ import { noteEntity, notesFeature, notesTable } from "../feature";
|
|
|
157
157
|
|
|
158
158
|
let stack: TestStack;
|
|
159
159
|
|
|
160
|
-
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
161
160
|
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
162
|
-
|
|
161
|
+
|
|
162
|
+
// Each test gets its own tenant and user, so rows from other tests never show up.
|
|
163
|
+
let testNumber = 0;
|
|
164
|
+
let TENANT_A = "";
|
|
165
|
+
let ALICE_ID = "";
|
|
166
|
+
let ALICE_EMAIL = "";
|
|
163
167
|
|
|
164
168
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
165
169
|
const NOW = (): Instant => getTemporal().Now.instant();
|
|
@@ -204,17 +208,23 @@ afterAll(async () => {
|
|
|
204
208
|
await stack.cleanup();
|
|
205
209
|
});
|
|
206
210
|
|
|
207
|
-
beforeEach(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
testNumber += 1;
|
|
213
|
+
const suffix = String(testNumber).padStart(12, "0");
|
|
214
|
+
TENANT_A = `00000000-0000-4000-8000-${suffix}`;
|
|
215
|
+
ALICE_ID = `11111111-1111-4111-8111-${suffix}`;
|
|
216
|
+
ALICE_EMAIL = `alice-${testNumber}@recipe.test`;
|
|
211
217
|
});
|
|
212
218
|
|
|
219
|
+
function noteId(index: number): string {
|
|
220
|
+
return `aaaaaaaa-aaaa-4aaa-8aaa-${String(testNumber * 10 + index).padStart(12, "0")}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
213
223
|
async function seedAlice(opts: { gracePeriodEnd?: Instant; status?: string } = {}): Promise<void> {
|
|
214
224
|
await seedRow(stack.db, userTable, {
|
|
215
225
|
id: ALICE_ID,
|
|
216
226
|
tenantId: TENANT_SYSTEM,
|
|
217
|
-
email:
|
|
227
|
+
email: ALICE_EMAIL,
|
|
218
228
|
passwordHash: "h",
|
|
219
229
|
displayName: "Alice",
|
|
220
230
|
locale: "de",
|
|
@@ -246,8 +256,8 @@ async function seedNote(id: string, title: string): Promise<void> {
|
|
|
246
256
|
describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domain", () => {
|
|
247
257
|
test("Export-Bundle enthaelt Note-Snippet via Hook", async () => {
|
|
248
258
|
await seedAlice();
|
|
249
|
-
await seedNote(
|
|
250
|
-
await seedNote(
|
|
259
|
+
await seedNote(noteId(1), "first");
|
|
260
|
+
await seedNote(noteId(2), "second");
|
|
251
261
|
|
|
252
262
|
const bundle = await runUserExport({
|
|
253
263
|
db: stack.db,
|
|
@@ -267,7 +277,7 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
|
|
|
267
277
|
|
|
268
278
|
test("Forget-Cron strategy=delete entfernt Notes (Default-Pfad)", async () => {
|
|
269
279
|
await seedAlice({ status: USER_STATUS.DeletionRequested, gracePeriodEnd: PAST() });
|
|
270
|
-
await seedNote(
|
|
280
|
+
await seedNote(noteId(1), "to-delete");
|
|
271
281
|
|
|
272
282
|
const result = await runForgetCleanup({
|
|
273
283
|
db: stack.db,
|
|
@@ -276,13 +286,13 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
|
|
|
276
286
|
});
|
|
277
287
|
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
278
288
|
|
|
279
|
-
const remaining = await
|
|
289
|
+
const remaining = await selectMany(stack.db, notesTable, { tenantId: TENANT_A });
|
|
280
290
|
expect(remaining.length).toBe(0);
|
|
281
291
|
});
|
|
282
292
|
|
|
283
293
|
test("Strategy=anonymize: authorId=null, Row bleibt (HR-Compliance-Pfad)", async () => {
|
|
284
294
|
await seedAlice();
|
|
285
|
-
await seedNote(
|
|
295
|
+
await seedNote(noteId(1), "stays-anonymous");
|
|
286
296
|
|
|
287
297
|
const usage = stack.registry
|
|
288
298
|
.getExtensionUsages(EXT_USER_DATA)
|
|
@@ -297,11 +307,9 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
|
|
|
297
307
|
| undefined;
|
|
298
308
|
await hooks?.delete?.({ db: stack.db, tenantId: TENANT_A, userId: ALICE_ID }, "anonymize");
|
|
299
309
|
|
|
300
|
-
const after =
|
|
301
|
-
`SELECT title, author_id FROM store_notes`,
|
|
302
|
-
)) as Array<{ title: string; author_id: string | null }>;
|
|
310
|
+
const after = await selectMany(stack.db, notesTable, { tenantId: TENANT_A });
|
|
303
311
|
expect(after).toHaveLength(1);
|
|
304
|
-
expect(after[0]?.
|
|
312
|
+
expect(after[0]?.authorId).toBeNull();
|
|
305
313
|
expect(after[0]?.title).toBe("stays-anonymous");
|
|
306
314
|
});
|
|
307
315
|
});
|
|
@@ -68,7 +68,7 @@ bun test
|
|
|
68
68
|
```ts
|
|
69
69
|
// webhook-step integration test — drives r.step.webhook.send end-to-end:
|
|
70
70
|
// handler appends step.dispatch-requested in TX, dispatcher subscription
|
|
71
|
-
//
|
|
71
|
+
// enqueues a delivery job after COMMIT, the job calls the (stubbed) fetch, follow-up
|
|
72
72
|
// step.dispatched / step.dispatch-failed events land on the same stream.
|
|
73
73
|
|
|
74
74
|
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -140,6 +140,7 @@ beforeAll(async () => {
|
|
|
140
140
|
stack = await setupTestStack({
|
|
141
141
|
features: [createStepDispatcherFeature(), createSecretsFeature(), webhookDemoFeature],
|
|
142
142
|
systemHooks: [],
|
|
143
|
+
jobs: { consumerLane: "worker" },
|
|
143
144
|
extraContext: ({ db, registry }) => ({
|
|
144
145
|
secrets: createSecretsContext({ db, masterKeyProvider, registry }),
|
|
145
146
|
}),
|
|
@@ -184,6 +185,7 @@ describe("webhook-step Sample", () => {
|
|
|
184
185
|
|
|
185
186
|
// Drain dispatcher (MSP runs async via runOnce in test mode)
|
|
186
187
|
await stack.eventDispatcher?.runOnce();
|
|
188
|
+
await stack.drainJobs();
|
|
187
189
|
|
|
188
190
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
189
191
|
const [calledUrl, init] = fetchMock.mock.calls[0]!;
|
|
@@ -202,6 +204,7 @@ describe("webhook-step Sample", () => {
|
|
|
202
204
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
203
205
|
|
|
204
206
|
await stack.eventDispatcher?.runOnce();
|
|
207
|
+
await stack.drainJobs();
|
|
205
208
|
|
|
206
209
|
expect(mailMock).toHaveBeenCalledTimes(1);
|
|
207
210
|
expect(mailMock).toHaveBeenCalledWith(
|
|
@@ -231,6 +234,7 @@ describe("webhook-step Sample", () => {
|
|
|
231
234
|
|
|
232
235
|
// And the inner handler's webhook fired
|
|
233
236
|
await stack.eventDispatcher?.runOnce();
|
|
237
|
+
await stack.drainJobs();
|
|
234
238
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
235
239
|
});
|
|
236
240
|
|
|
@@ -243,6 +247,7 @@ describe("webhook-step Sample", () => {
|
|
|
243
247
|
expect(res.status).toBe(500);
|
|
244
248
|
|
|
245
249
|
await stack.eventDispatcher?.runOnce();
|
|
250
|
+
await stack.drainJobs();
|
|
246
251
|
|
|
247
252
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
248
253
|
const rows = await selectMany(stack.db, incidentTable);
|
|
@@ -261,6 +266,7 @@ describe("access-denied — role-restricted handlers", () => {
|
|
|
261
266
|
);
|
|
262
267
|
expect(error.code).toBe("access_denied");
|
|
263
268
|
await stack.eventDispatcher?.runOnce();
|
|
269
|
+
await stack.drainJobs();
|
|
264
270
|
expect(mailMock).not.toHaveBeenCalled();
|
|
265
271
|
});
|
|
266
272
|
|
|
@@ -287,6 +293,7 @@ describe("access-denied — role-restricted handlers", () => {
|
|
|
287
293
|
);
|
|
288
294
|
expect(error.code).toBe("access_denied");
|
|
289
295
|
await stack.eventDispatcher?.runOnce();
|
|
296
|
+
await stack.drainJobs();
|
|
290
297
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
291
298
|
});
|
|
292
299
|
});
|
|
@@ -317,6 +324,7 @@ describe("incident:open-authenticated — tenant-owned webhook auth secret", ()
|
|
|
317
324
|
adminB,
|
|
318
325
|
);
|
|
319
326
|
await stack.eventDispatcher?.runOnce();
|
|
327
|
+
await stack.drainJobs();
|
|
320
328
|
|
|
321
329
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
322
330
|
const [, init] = fetchMock.mock.calls[0]!;
|
|
@@ -348,6 +356,7 @@ describe("incident:open-authenticated — tenant-owned webhook auth secret", ()
|
|
|
348
356
|
adminA,
|
|
349
357
|
);
|
|
350
358
|
await stack.eventDispatcher?.runOnce();
|
|
359
|
+
await stack.drainJobs();
|
|
351
360
|
|
|
352
361
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
353
362
|
|
|
@@ -378,6 +387,7 @@ describe("incident:open-authenticated — tenant-owned webhook auth secret", ()
|
|
|
378
387
|
adminC,
|
|
379
388
|
);
|
|
380
389
|
await stack.eventDispatcher?.runOnce();
|
|
390
|
+
await stack.drainJobs();
|
|
381
391
|
|
|
382
392
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
383
393
|
const failedEvents = await selectMany(stack.db, eventsTable, {
|
|
@@ -983,7 +983,7 @@ export function startCronScheduler(
|
|
|
983
983
|
|
|
984
984
|
```ts
|
|
985
985
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
986
|
-
import type
|
|
986
|
+
import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
|
|
987
987
|
|
|
988
988
|
export async function selectExpiredSuspensionEvents(
|
|
989
989
|
db: DbRunner,
|
|
@@ -993,7 +993,7 @@ export async function selectExpiredSuspensionEvents(
|
|
|
993
993
|
`SELECT * FROM "kumiko_events"
|
|
994
994
|
WHERE "type" = ANY($1::text[])
|
|
995
995
|
AND (("payload"->>'wakeAt')::timestamptz < now() OR ("payload"->>'timeoutAt')::timestamptz < now())`,
|
|
996
|
-
[eventTypes
|
|
996
|
+
[toPostgresArrayLiteral(eventTypes)],
|
|
997
997
|
)) as Record<string, unknown>[];
|
|
998
998
|
}
|
|
999
999
|
```
|
package/agents/examples.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
| `currencies-per-tenant` | Tenant-scoped money fields. Commas matter. | `examples/currencies-per-tenant.md` |
|
|
24
24
|
| `custom-fields-basic` | Tenant-defined extra fields on an entity without writing migrations. | `examples/custom-fields-basic.md` |
|
|
25
25
|
| `custom-handlers` | Beyond CRUD — when your domain refuses to fit a table. | `examples/custom-handlers.md` |
|
|
26
|
+
| `dashboard-stats` | Key figures on a dashboard: stat panels backed by one counting query. | `examples/dashboard-stats.md` |
|
|
26
27
|
| `delivery-notifications` | Delivery flows plus notifications. Package tracking, minus the tracking. | `examples/delivery-notifications.md` |
|
|
27
28
|
| `designer-demo` | Kumiko sample feature — scaffolded by `bun kumiko create` | `examples/designer-demo.md` |
|
|
28
29
|
| `e2e-generator` | generateE2ESpec + withBootValidatorFixture — Registry → JSON-Specs für externen Playwright-Worker. Zeigt die 4 Test-Kinds, Feldtyp→Interaktion-Mapping und welche Feldtypen heute noch nicht generisch unterstützt sind. | `examples/e2e-generator.md` |
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
| `managed-pages` | Tenant-editable, server-rendered public pages with per-tenant branding + tier-gated sanitized custom CSS — managed-pages wired up with config + anonymousAccess. | `examples/managed-pages.md` |
|
|
47
48
|
| `notes-history-basic` | Append-only note history — who wrote what, when — attached to any entity without changing it. | `examples/notes-history-basic.md` |
|
|
48
49
|
| `ownership` | Entity- and Field-level Row-Ownership: from() helper, Straddle-safe multi-role checks, Field-level Read/Write filtering. | `examples/ownership.md` |
|
|
49
|
-
| `pdf-audit-export` | Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and
|
|
50
|
+
| `pdf-audit-export` | Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and uploads the bytes before the transaction (uploadFileBytesBeforeTransaction) and stores them as a FileRef (createFileRefFromUploadedBytes). | `examples/pdf-audit-export.md` |
|
|
50
51
|
| `pipeline-basics` | M.1 Tier-1 step-vocabulary showcase: every step in one inventory-management feature (aggregate.{create,update,appendEvent}, read.{findOne,findMany}, branch, forEach, compute, return, unsafeProjection{Upsert,Delete}). | `examples/pipeline-basics.md` |
|
|
51
52
|
| `public-share-token` | Generic share-token entity: mint hash-stored token, anonymous read-by-token with IP rate-limit. | `examples/public-share-token.md` |
|
|
52
53
|
| `rate-limiting` | End-to-end rate limiting: L1 global-IP + L2 auth-endpoints + L3 handler opt-in. | `examples/rate-limiting.md` |
|
package/agents/features.md
CHANGED
|
@@ -538,7 +538,7 @@ Requires: `template-resolver`
|
|
|
538
538
|
|
|
539
539
|
## renderer-mail-html
|
|
540
540
|
|
|
541
|
-
A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, in-code
|
|
541
|
+
A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
|
|
542
542
|
|
|
543
543
|
Requires: `renderer-foundation`, `template-resolver`
|
|
544
544
|
|
|
@@ -552,7 +552,7 @@ Requires: `renderer-foundation`
|
|
|
552
552
|
|
|
553
553
|
## renderer-puppeteer-client
|
|
554
554
|
|
|
555
|
-
A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content
|
|
555
|
+
A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
|
|
556
556
|
|
|
557
557
|
Requires: `renderer-foundation`
|
|
558
558
|
|
|
@@ -594,7 +594,7 @@ Exposes APIs: `sessions.revokeAllForUser`
|
|
|
594
594
|
|
|
595
595
|
## step-dispatcher
|
|
596
596
|
|
|
597
|
-
Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits.
|
|
597
|
+
Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. An `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event enqueues one `step-dispatcher:job:deliver` BullMQ job per dispatch (Redis carries only the stream id and tenant, never the request payload). The job performs the actual HTTP or mail delivery and appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream, so the outcome is recorded in the event log without a separate status table. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome is recorded after the final attempt and the payload key is erased only then. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded immediately. At most 2 deliveries per tenant run at once (`maxPerTenant` with `onLimit: "defer"`), so a slow receiver of one tenant does not delay other tenants. Requires a job runner (Redis) and a worker consuming the job's default `worker` lane; without a job runner the projection fails loudly. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across retries and redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.
|
|
598
598
|
|
|
599
599
|
Category: infrastructure
|
|
600
600
|
|
|
@@ -755,7 +755,7 @@ Requires: `user`, `auth-email-password`, `user-data-rights`, `user-data-rights-d
|
|
|
755
755
|
|
|
756
756
|
## waitlist
|
|
757
757
|
|
|
758
|
-
Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response
|
|
758
|
+
Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.
|
|
759
759
|
|
|
760
760
|
Category: data
|
|
761
761
|
|
|
@@ -6,7 +6,6 @@ import { type AuthSessionChecker, type AuthSessionStatus, type TokenVerifier } f
|
|
|
6
6
|
import { type ClientIpResolver } from "./client-ip.js";
|
|
7
7
|
import type { JwtHelper } from "./jwt.js";
|
|
8
8
|
import type { PostAuthLandingResolver } from "./post-auth-landing.js";
|
|
9
|
-
export declare function assertRetiredCookieDomains(retiredCookieDomains: readonly string[] | undefined, cookieDomain: string | undefined): void;
|
|
10
9
|
export type LoginRateLimiter = {
|
|
11
10
|
check(key: string): Promise<boolean>;
|
|
12
11
|
reset(key: string): Promise<void>;
|