@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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/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/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +3 -3
- 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 +21 -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/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- 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 +70 -47
- 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 +1 -1
- package/dist/engine/index.js +1 -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/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 +1 -0
- package/dist/jobs/job-runner.js +116 -33
- 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/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 +305 -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
|
});
|
|
@@ -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
|
|
|
@@ -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>;
|
package/dist/api/auth-routes.js
CHANGED
|
@@ -61,7 +61,7 @@ function normalizeCookieDomain(domain) {
|
|
|
61
61
|
}
|
|
62
62
|
// A malformed entry would corrupt the Set-Cookie header; an entry equal to the
|
|
63
63
|
// current cookieDomain would make login delete the cookie it just sets.
|
|
64
|
-
|
|
64
|
+
function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
|
|
65
65
|
const current = cookieDomain === undefined ? undefined : normalizeCookieDomain(cookieDomain);
|
|
66
66
|
for (const entry of retiredCookieDomains ?? []) {
|
|
67
67
|
if (entry === "" || normalizeCookieDomain(entry) === "") {
|
|
@@ -322,6 +322,10 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
322
322
|
retiredCookieDomains: config.retiredCookieDomains,
|
|
323
323
|
});
|
|
324
324
|
assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
|
|
325
|
+
if (config.mfaVerifyHandler && !config.switchTenantMfaGateHandler) {
|
|
326
|
+
throw new Error("createAuthRoutes: mfaVerifyHandler is set but switchTenantMfaGateHandler is not; " +
|
|
327
|
+
"POST /auth/switch-tenant would mint admin sessions without the second factor");
|
|
328
|
+
}
|
|
325
329
|
const retiredCookieDomains = config.retiredCookieDomains ?? [];
|
|
326
330
|
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
327
331
|
// see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
|
|
@@ -330,19 +334,45 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
330
334
|
// shared "unknown" bucket.
|
|
331
335
|
const clientIpResolver = config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
|
|
332
336
|
const getClientIp = (c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
|
|
333
|
-
|
|
337
|
+
// undefined = no gate wired (setup without MFA). "clearedRoles" are the roles the gate decided on,
|
|
338
|
+
// so the minted session cannot differ from what the gate checked.
|
|
339
|
+
async function switchTenantMfaGate(c, user, targetTenantId) {
|
|
334
340
|
if (!config.switchTenantMfaGateHandler)
|
|
335
341
|
return undefined;
|
|
336
342
|
const gate = await dispatcher.write(config.switchTenantMfaGateHandler, { userId: user.id, tenantId: targetTenantId }, createSystemUser(user.tenantId));
|
|
337
343
|
if (!gate.isSuccess) {
|
|
338
344
|
const status = gate.error.httpStatus; // @cast-boundary engine-payload
|
|
339
|
-
return c.json({ isSuccess: false, error: gate.error }, status);
|
|
345
|
+
return { response: c.json({ isSuccess: false, error: gate.error }, status) };
|
|
340
346
|
}
|
|
341
347
|
// @cast-boundary engine-payload — the gate handler's three-shape result
|
|
342
348
|
const gateData = gate.data;
|
|
343
349
|
if (gateData.kind === "mfa-gate-clear")
|
|
344
|
-
return
|
|
345
|
-
return pendingMfaStepResponse(c, gateData);
|
|
350
|
+
return { clearedRoles: gateData.roles };
|
|
351
|
+
return { response: pendingMfaStepResponse(c, gateData) };
|
|
352
|
+
}
|
|
353
|
+
// Global roles (SystemAdmin etc.) come from the user feature when userQuery is wired, so they survive
|
|
354
|
+
// the tenant switch. buildSessionRoles calls stripForbiddenMembershipRoles on the membership side only — a backstop
|
|
355
|
+
// for a role a projection rebuild resurrected past command-time validation (engine/membership-roles).
|
|
356
|
+
async function resolveSwitchTenantRoles(user, membership) {
|
|
357
|
+
let globalRoles = [];
|
|
358
|
+
if (config.userQuery) {
|
|
359
|
+
try {
|
|
360
|
+
// @cast-boundary engine-payload — dispatcher.query returnt unknown,
|
|
361
|
+
// Per the AuthUserRow contract, userQuery returns a row with a roles column.
|
|
362
|
+
const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
|
|
363
|
+
// roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
|
|
364
|
+
// JSON-encoded text from pre-0.217.0 rows.
|
|
365
|
+
globalRoles = parseRoles(userRow?.roles ?? null);
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
// Non-fatal: when global roles cannot be resolved the switch continues with tenant roles
|
|
369
|
+
// only. A server error carrying just the cause would hurt the UX more than a switch without
|
|
370
|
+
// SystemAdmin, which the user notices and reports.
|
|
371
|
+
if (!isUnknownHandlerError(e))
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return buildSessionRoles(globalRoles, membership.roles);
|
|
346
376
|
}
|
|
347
377
|
// Shared tail of every route that ends a request logged-in: create the
|
|
348
378
|
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
@@ -711,13 +741,13 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
711
741
|
}
|
|
712
742
|
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
713
743
|
const token = await mintSessionAndRespond(c, data.session);
|
|
714
|
-
const
|
|
715
|
-
flow: "signup",
|
|
744
|
+
const signupOutcome = {
|
|
716
745
|
roles: data.session.roles,
|
|
717
746
|
tenantId: data.session.tenantId,
|
|
718
747
|
tenantKey: data.tenantKey,
|
|
719
748
|
...(data.handover !== undefined && { handover: data.handover }),
|
|
720
|
-
}
|
|
749
|
+
};
|
|
750
|
+
const landingPath = landingPathFragment({ flow: "signup", ...signupOutcome });
|
|
721
751
|
return c.json({
|
|
722
752
|
isSuccess: true,
|
|
723
753
|
token,
|
|
@@ -962,49 +992,20 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
962
992
|
return switchTenantRejectionResponse(c, activeMembership.reason);
|
|
963
993
|
}
|
|
964
994
|
const membership = activeMembership.membership;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
let globalRoles = [];
|
|
970
|
-
if (config.userQuery) {
|
|
971
|
-
try {
|
|
972
|
-
// @cast-boundary engine-payload — dispatcher.query returnt unknown,
|
|
973
|
-
// userQuery liefert per AuthUserRow-Contract eine row mit roles-spalte.
|
|
974
|
-
const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
|
|
975
|
-
// roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
|
|
976
|
-
// JSON-encoded text from pre-0.217.0 rows.
|
|
977
|
-
globalRoles = parseRoles(userRow?.roles ?? null);
|
|
978
|
-
}
|
|
979
|
-
catch (e) {
|
|
980
|
-
// Non-fatal: globale Rollen kann nicht aufgelöst werden → switch
|
|
981
|
-
// läuft weiter mit nur tenant-rollen. Server-error mit nur dem
|
|
982
|
-
// Cause ohne Stack hochwerfen wäre für die UX schlimmer als ein
|
|
983
|
-
// Tenant-Switch ohne SystemAdmin (User merkt's und meldet's). Log
|
|
984
|
-
// it via the dispatcher so Ops sieht's.
|
|
985
|
-
if (!isUnknownHandlerError(e))
|
|
986
|
-
throw e;
|
|
987
|
-
}
|
|
988
|
-
}
|
|
995
|
+
const gate = await switchTenantMfaGate(c, user, targetTenantId);
|
|
996
|
+
if (gate !== undefined && "response" in gate)
|
|
997
|
+
return gate.response;
|
|
998
|
+
const sessionRoles = gate !== undefined ? gate.clearedRoles : await resolveSwitchTenantRoles(user, membership);
|
|
989
999
|
// Issue new JWT with the target tenant and its roles. Claims MUST be
|
|
990
1000
|
// recomputed for the new tenant — stale claims from the previous
|
|
991
1001
|
// tenant would leak identity facts across tenancies (e.g. teamId from
|
|
992
1002
|
// tenant A accidentally surviving into tenant B's session). The
|
|
993
1003
|
// resolver runs each feature's r.authClaims() hook under the new
|
|
994
1004
|
// TenantDb scope.
|
|
995
|
-
// buildSessionRoles calls stripForbiddenMembershipRoles internally and
|
|
996
|
-
// strips reserved roles from the membership side only — globalRoles
|
|
997
|
-
// (where SystemAdmin legitimately lives) is never filtered. Backstop for a
|
|
998
|
-
// membership role that a projection rebuild resurrected past command-time
|
|
999
|
-
// validation (see engine/membership-roles).
|
|
1000
|
-
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
1001
|
-
const pendingMfaStep = await switchTenantMfaGateResponse(c, user, targetTenantId);
|
|
1002
|
-
if (pendingMfaStep !== undefined)
|
|
1003
|
-
return pendingMfaStep;
|
|
1004
1005
|
const targetSession = {
|
|
1005
1006
|
id: user.id,
|
|
1006
1007
|
tenantId: targetTenantId,
|
|
1007
|
-
roles:
|
|
1008
|
+
roles: sessionRoles,
|
|
1008
1009
|
// Tenant-independent prefs must survive the switch (fw#2343).
|
|
1009
1010
|
...tenantIndependentPrefs(user),
|
|
1010
1011
|
};
|
|
@@ -1023,7 +1024,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
1023
1024
|
// fresh csrfToken is minted (inside the helper) so a compromised csrf-
|
|
1024
1025
|
// value can't cross a tenant boundary.
|
|
1025
1026
|
const newToken = await mintSessionAndRespond(c, sessionForJwt);
|
|
1026
|
-
return c.json({ token: newToken, tenantId: targetTenantId, roles:
|
|
1027
|
+
return c.json({ token: newToken, tenantId: targetTenantId, roles: sessionRoles });
|
|
1027
1028
|
});
|
|
1028
1029
|
return api;
|
|
1029
1030
|
}
|
package/dist/api/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type { MetricsRouteOptions } from "./route-registrars.js";
|
|
|
26
26
|
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
27
27
|
export { createApiRoutes } from "./routes.js";
|
|
28
28
|
export type { KumikoServer, ServerOptions } from "./server.js";
|
|
29
|
-
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server.js";
|
|
29
|
+
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.js";
|
|
30
30
|
export type { ResolvedSessionCookies, SessionCookieBase, SessionCookieNames, } from "./session-cookies.js";
|
|
31
31
|
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
|
|
32
32
|
export type { SseBroker, SseClient, SseEvent } from "./sse-broker.js";
|
package/dist/api/index.js
CHANGED
|
@@ -15,7 +15,7 @@ export { readonlyRequestContext as requestContext, } from "./request-context.js"
|
|
|
15
15
|
export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
|
|
16
16
|
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
17
17
|
export { createApiRoutes } from "./routes.js";
|
|
18
|
-
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server.js";
|
|
18
|
+
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.js";
|
|
19
19
|
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
|
|
20
20
|
export { createSseBroker } from "./sse-broker.js";
|
|
21
21
|
export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
|