@cosmicdrift/kumiko-framework 0.365.0 → 0.366.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 +2 -2
- package/agents/examples/access-control.md +32 -30
- package/agents/examples/anonymous-access-multitenant.md +14 -16
- package/agents/examples/anonymous-access.md +36 -43
- package/agents/examples/auth-foundation-providers.md +1 -1
- package/agents/examples/basic-entity.md +1 -1
- package/agents/examples/boot-check.md +3 -3
- package/agents/examples/chat-channels.md +1 -1
- package/agents/examples/cross-feature-events.md +7 -7
- package/agents/examples/currencies-global.md +3 -3
- package/agents/examples/currencies-per-tenant.md +2 -2
- package/agents/examples/custom-fields-basic.md +4 -3
- package/agents/examples/custom-handlers.md +40 -44
- package/agents/examples/dashboard-stats.md +6 -6
- package/agents/examples/delivery-notifications.md +9 -9
- package/agents/examples/designer-demo.md +2 -1
- package/agents/examples/embedded-entity-form.md +13 -13
- package/agents/examples/embedded.md +1 -1
- package/agents/examples/encrypted-tenant-config.md +1 -1
- package/agents/examples/error-contract.md +25 -25
- package/agents/examples/event-sourcing.md +96 -76
- package/agents/examples/feature-toggles.md +29 -21
- package/agents/examples/files-post-processing.md +22 -26
- package/agents/examples/idempotency.md +17 -19
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +10 -12
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +59 -39
- package/agents/examples/managed-config.md +9 -9
- package/agents/examples/managed-pages.md +1 -1
- package/agents/examples/notes-history-basic.md +3 -3
- package/agents/examples/pdf-audit-export.md +7 -7
- package/agents/examples/pipeline-basics.md +8 -8
- package/agents/examples/public-share-token.md +1 -1
- package/agents/examples/rate-limiting.md +7 -9
- package/agents/examples/record-detail-layout.md +26 -20
- package/agents/examples/reference-data.md +1 -1
- package/agents/examples/relations.md +2 -2
- package/agents/examples/screens-nav.md +2 -2
- package/agents/examples/search.md +17 -19
- package/agents/examples/security-baseline.md +1 -1
- package/agents/examples/state-machine.md +1 -1
- package/agents/examples/tags-basic.md +3 -3
- package/agents/examples/tenant-settings.md +2 -2
- package/agents/examples/timezones.md +12 -12
- package/agents/examples/user-data-rights.md +3 -3
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/webhook-step.md +12 -2
- package/agents/examples/writeform-section.md +21 -15
- package/agents/features.md +1 -1
- package/agents/widgets.md +5 -0
- package/dist/api/server.js +20 -4
- package/dist/bun-db/query.js +16 -1
- package/dist/crypto/in-memory-kms-adapter.js +4 -7
- package/dist/crypto/pg-kms-adapter.js +10 -6
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/event-store-executor-write.js +16 -12
- package/dist/engine/boot-validator/api-ext.js +2 -2
- package/dist/engine/boot-validator/config-deps.js +1 -1
- package/dist/engine/boot-validator/handler-field-references.js +3 -10
- package/dist/engine/boot-validator/screens.js +1 -1
- package/dist/engine/build-app-schema.js +3 -6
- package/dist/engine/define-feature.js +5 -5
- package/dist/engine/entity-handlers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +5 -5
- package/dist/engine/factories.d.ts +11 -11
- package/dist/engine/factories.js +11 -11
- package/dist/engine/feature-ast/extractors/round2.js +16 -2
- package/dist/engine/feature-ast/render.js +4 -7
- package/dist/engine/feature-config-events-jobs.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +4 -23
- package/dist/engine/feature-entity-handlers.js +77 -100
- package/dist/engine/feature-ui-extensions.d.ts +9 -14
- package/dist/engine/feature-ui-extensions.js +22 -14
- package/dist/engine/handler-field-references.d.ts +25 -0
- package/dist/engine/handler-field-references.js +80 -6
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +2 -2
- package/dist/engine/object-form.d.ts +2 -0
- package/dist/engine/object-form.js +13 -10
- package/dist/engine/registry-state.js +2 -2
- package/dist/engine/registry-validate.d.ts +2 -0
- package/dist/engine/registry-validate.js +42 -2
- package/dist/engine/registry.js +3 -1
- package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/files/feature.js +1 -1
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +83 -17
- package/dist/jobs/max-per-tenant.d.ts +8 -0
- package/dist/jobs/max-per-tenant.js +14 -0
- package/dist/pipeline/dispatch-query.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +2 -1
- package/dist/pipeline/dispatch-shared.js +3 -6
- 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/tenant-timezone-config-key.d.ts +1 -0
- package/dist/pipeline/tenant-timezone-config-key.js +6 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/manual-clock.d.ts +14 -0
- package/dist/testing/manual-clock.js +12 -0
- package/dist/time/index.d.ts +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +9 -4
- package/dist/upgrade-cli.js +13 -2
- package/package.json +4 -4
- package/src/changes.json +58 -0
- package/src/scripts/codemod/README.md +23 -0
- package/src/scripts/codemod/object-form.ts +709 -0
|
@@ -567,7 +567,7 @@ import type { InvoiceStatus } from "./reducer";
|
|
|
567
567
|
export { invoiceEntity } from "./entities/invoice";
|
|
568
568
|
|
|
569
569
|
export const stateMachineFeature = defineFeature("billing", (r) => {
|
|
570
|
-
r.entity("invoice", invoiceEntity);
|
|
570
|
+
r.entity({ name: "invoice", ...invoiceEntity });
|
|
571
571
|
|
|
572
572
|
// Domain events — one per intent. Names live in events.ts so the reducer
|
|
573
573
|
// and the projection stay in sync. The auto "invoice.created" event from
|
|
@@ -85,7 +85,7 @@ See the [tags feature reference](/en/bundled-features/tags/) for the full set.
|
|
|
85
85
|
tags → core bundle: tag + tag-assignment entities,
|
|
86
86
|
create/assign/remove handlers, list queries
|
|
87
87
|
note-management → our feature: a plain `note` entity. Declares
|
|
88
|
-
r.requires("tags") only so the bundle is mounted —
|
|
88
|
+
r.requires({ features: ["tags"] }) only so the bundle is mounted —
|
|
89
89
|
the note itself is completely tag-agnostic.
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -321,9 +321,9 @@ export const noteFeature = defineFeature("note-management", (r) => {
|
|
|
321
321
|
// tags is non-optional for this recipe: the demo tags notes. The note
|
|
322
322
|
// feature itself stays completely tag-agnostic — it only declares the
|
|
323
323
|
// dependency so the bundle is mounted.
|
|
324
|
-
r.requires("tags");
|
|
324
|
+
r.requires({ features: ["tags"] });
|
|
325
325
|
|
|
326
|
-
r.entity("note", noteEntity);
|
|
326
|
+
r.entity({ name: "note", ...noteEntity });
|
|
327
327
|
|
|
328
328
|
r.writeHandler({
|
|
329
329
|
name: "note:create",
|
|
@@ -35,7 +35,7 @@ export const invoiceEntity = createEntity({
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
export const invoiceFeature = defineFeature("invoice", (r) => {
|
|
38
|
-
r.entity("invoice", invoiceEntity);
|
|
38
|
+
r.entity({ name: "invoice", ...invoiceEntity });
|
|
39
39
|
r.writeHandler(
|
|
40
40
|
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
41
41
|
access: { roles: ["Admin"] },
|
|
@@ -263,7 +263,7 @@ export const invoiceEntity = createEntity({
|
|
|
263
263
|
const ACCESS = { roles: ["Admin"] } as const;
|
|
264
264
|
|
|
265
265
|
export const invoiceFeature = defineFeature("invoice", (r) => {
|
|
266
|
-
r.entity("invoice", invoiceEntity);
|
|
266
|
+
r.entity({ name: "invoice", ...invoiceEntity });
|
|
267
267
|
r.writeHandler(
|
|
268
268
|
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
269
269
|
access: ACCESS,
|
|
@@ -336,12 +336,12 @@ export const timezonesFeature = defineFeature("timezones", (r) => {
|
|
|
336
336
|
});
|
|
337
337
|
r.writeHandler(defineEntityDeleteHandler("delivery", deliveryEntity, adminWrite));
|
|
338
338
|
|
|
339
|
-
r.queryHandler(
|
|
340
|
-
"day-window",
|
|
341
|
-
z.object({
|
|
339
|
+
r.queryHandler({
|
|
340
|
+
name: "day-window",
|
|
341
|
+
schema: z.object({
|
|
342
342
|
zone: z.string().refine(isValidIanaTimeZone, { message: "Invalid IANA timezone" }),
|
|
343
343
|
}),
|
|
344
|
-
async (query, ctx) => {
|
|
344
|
+
handler: async (query, ctx) => {
|
|
345
345
|
const { start, end } = ctx.tz.todayRange(query.payload.zone);
|
|
346
346
|
return {
|
|
347
347
|
zone: query.payload.zone,
|
|
@@ -350,17 +350,17 @@ export const timezonesFeature = defineFeature("timezones", (r) => {
|
|
|
350
350
|
end: end.toString(),
|
|
351
351
|
};
|
|
352
352
|
},
|
|
353
|
-
openRead,
|
|
354
|
-
);
|
|
353
|
+
...openRead,
|
|
354
|
+
});
|
|
355
355
|
|
|
356
|
-
r.queryHandler(
|
|
357
|
-
"zone-at",
|
|
358
|
-
z.object({ latitude: z.number(), longitude: z.number() }),
|
|
359
|
-
async (query, ctx) => {
|
|
356
|
+
r.queryHandler({
|
|
357
|
+
name: "zone-at",
|
|
358
|
+
schema: z.object({ latitude: z.number(), longitude: z.number() }),
|
|
359
|
+
handler: async (query, ctx) => {
|
|
360
360
|
const zone = await ctx.tz.fromCoordinates(query.payload);
|
|
361
361
|
return { zone };
|
|
362
362
|
},
|
|
363
|
-
openRead,
|
|
364
|
-
);
|
|
363
|
+
...openRead,
|
|
364
|
+
});
|
|
365
365
|
});
|
|
366
366
|
```
|
|
@@ -19,8 +19,8 @@ forget logic:
|
|
|
19
19
|
|
|
20
20
|
```ts illustration
|
|
21
21
|
defineFeature("notes", (r) => {
|
|
22
|
-
r.requires("user-data-rights");
|
|
23
|
-
r.entity("note", noteEntity);
|
|
22
|
+
r.requires({ features: ["user-data-rights"] });
|
|
23
|
+
r.entity({ name: "note", ...noteEntity });
|
|
24
24
|
|
|
25
25
|
r.useExtension(EXT_USER_DATA, "note", {
|
|
26
26
|
export: async (ctx) => ({
|
|
@@ -375,7 +375,7 @@ export const noteEntity = createEntity({
|
|
|
375
375
|
export const notesTable = deriveEntityTableMeta("note", noteEntity, { source: "unmanaged" });
|
|
376
376
|
|
|
377
377
|
export const notesFeature = defineFeature("notes", (r) => {
|
|
378
|
-
r.requires("user-data-rights");
|
|
378
|
+
r.requires({ features: ["user-data-rights"] });
|
|
379
379
|
// store_notes is a direct-write store: the forget hook below `updateMany`/
|
|
380
380
|
// `deleteMany`s rows WITHOUT emitting lifecycle events. r.entity would make
|
|
381
381
|
// it a rebuildable implicit projection whose replay finds zero note events
|
|
@@ -187,7 +187,7 @@ export function createAccountFeature(): FeatureDefinition {
|
|
|
187
187
|
"entry — user-profile registers the `profile` screen itself " +
|
|
188
188
|
"(fw#2312 declarative projectionDetail).",
|
|
189
189
|
);
|
|
190
|
-
r.requires("user-profile");
|
|
190
|
+
r.requires({ features: ["user-profile"] });
|
|
191
191
|
|
|
192
192
|
r.nav({
|
|
193
193
|
id: "profile",
|
|
@@ -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, {
|
|
@@ -426,7 +436,7 @@ const incidentExecutor = createEventStoreExecutor(incidentTable, incidentEntity,
|
|
|
426
436
|
const INCIDENT_HOOK_URL = "https://hooks.example/incident";
|
|
427
437
|
|
|
428
438
|
export const webhookDemoFeature = defineFeature("webhook-demo", (r) => {
|
|
429
|
-
r.entity("incident", incidentEntity);
|
|
439
|
+
r.entity({ name: "incident", ...incidentEntity });
|
|
430
440
|
r.requires.step("webhook.send");
|
|
431
441
|
r.requires.step("mail.send");
|
|
432
442
|
r.requires.step("callFeature");
|
|
@@ -127,10 +127,16 @@ describe("writeform-section sample — boot-validator catches author mistakes",
|
|
|
127
127
|
test("a writeForm field without its pseudo-entity translation key fails boot", () => {
|
|
128
128
|
const broken = defineFeature("broken-note-desk", (r) => {
|
|
129
129
|
r.translations({ keys: { "screen:x.title": { en: "X" } } });
|
|
130
|
-
r.queryHandler(
|
|
130
|
+
r.queryHandler({
|
|
131
|
+
name: "foo:detail",
|
|
132
|
+
schema: z.object({ id: z.string() }),
|
|
133
|
+
handler: async () => ({}),
|
|
131
134
|
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
132
135
|
});
|
|
133
|
-
r.writeHandler(
|
|
136
|
+
r.writeHandler({
|
|
137
|
+
name: "save",
|
|
138
|
+
schema: z.object({}),
|
|
139
|
+
handler: async () => ({ isSuccess: true as const, data: null }),
|
|
134
140
|
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
135
141
|
});
|
|
136
142
|
r.screen({
|
|
@@ -249,19 +255,19 @@ export const noteDeskFeature = defineFeature("note-desk", (r) => {
|
|
|
249
255
|
access: open.access,
|
|
250
256
|
});
|
|
251
257
|
|
|
252
|
-
r.queryHandler(
|
|
253
|
-
"note:summary",
|
|
254
|
-
z.object({ id: z.string() }),
|
|
255
|
-
async (query) => ({ id: query.payload.id, ...NOTE_DETAIL_FIELDS }),
|
|
256
|
-
open,
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
r.writeHandler(
|
|
260
|
-
"note:comment",
|
|
261
|
-
commentPayloadSchema,
|
|
262
|
-
async () => ({ isSuccess: true as const, data: null }),
|
|
263
|
-
open,
|
|
264
|
-
);
|
|
258
|
+
r.queryHandler({
|
|
259
|
+
name: "note:summary",
|
|
260
|
+
schema: z.object({ id: z.string() }),
|
|
261
|
+
handler: async (query) => ({ id: query.payload.id, ...NOTE_DETAIL_FIELDS }),
|
|
262
|
+
...open,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
r.writeHandler({
|
|
266
|
+
name: "note:comment",
|
|
267
|
+
schema: commentPayloadSchema,
|
|
268
|
+
handler: async () => ({ isSuccess: true as const, data: null }),
|
|
269
|
+
...open,
|
|
270
|
+
});
|
|
265
271
|
|
|
266
272
|
r.screen({
|
|
267
273
|
id: "note-detail",
|
package/agents/features.md
CHANGED
|
@@ -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
|
|
package/agents/widgets.md
CHANGED
|
@@ -147,6 +147,9 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
147
147
|
| `createKumikoApp` | |
|
|
148
148
|
| `createPublicSurface` | |
|
|
149
149
|
| `createStaticLocaleResolver` | Default-Resolver für Apps ohne eigene i18n-Schicht. |
|
|
150
|
+
| `downloadFileRef` | |
|
|
151
|
+
| `fetchFileRefBlob` | |
|
|
152
|
+
| `fileNameFromContentDisposition` | |
|
|
150
153
|
| `filterAppSchemaNavsByAllowlist` | |
|
|
151
154
|
| `filterByAccess` | |
|
|
152
155
|
| `formatMoney` | |
|
|
@@ -158,6 +161,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
158
161
|
| `readLoadedBuild` | |
|
|
159
162
|
| `reparentAppSchemaNavs` | |
|
|
160
163
|
| `resolveDefaultId` | |
|
|
164
|
+
| `saveBlobAsFile` | |
|
|
161
165
|
| `smoothPath` | Quadratic-durch-Mittelpunkte-Trick: glättet die Zick-Zack-Linie ohne Overshoot (~5 Zeilen statt Catmull-Rom/Bezier-Fit). |
|
|
162
166
|
| `synthesizeActionFormEntity` | Baut eine minimale EntityDefinition aus den Inline-Fields eines ActionForm- oder SecretMint-Screens. |
|
|
163
167
|
| `synthesizeActionFormScreen` | Wandelt ein ActionFormScreenDefinition oder SecretMintScreenDefinition in die EntityEditScreen-Shape die RenderEdit erwartet. |
|
|
@@ -178,4 +182,5 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
178
182
|
|
|
179
183
|
| Name | Description |
|
|
180
184
|
| --- | --- |
|
|
185
|
+
| `FileDownloadFailedError` | |
|
|
181
186
|
| `FileUploadFailedError` | Carries the server's own `error` message when the upload endpoint sent one. |
|
package/dist/api/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildAppSchema } from "../engine/build-app-schema.js";
|
|
|
6
6
|
import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
|
|
7
7
|
import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles.js";
|
|
8
8
|
import { runsInLane } from "../engine/run-in.js";
|
|
9
|
+
import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from "../engine/strip-disabled-features-from-app-schema.js";
|
|
9
10
|
import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
|
|
10
11
|
import { isFileField, } from "../engine/types/index.js";
|
|
11
12
|
import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
|
|
@@ -310,6 +311,13 @@ export function buildServer(options) {
|
|
|
310
311
|
const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
|
|
311
312
|
const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
|
|
312
313
|
const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
|
|
314
|
+
const mspJobs = jobRunnerForTriggers?.dispatch
|
|
315
|
+
? {
|
|
316
|
+
dispatch: async (jobName, payload, dispatchOptions) => {
|
|
317
|
+
await jobRunnerForTriggers.dispatch?.(jobName, payload, dispatchOptions);
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
: undefined;
|
|
313
321
|
const systemConsumers = [];
|
|
314
322
|
if (sseConsumerEnabled) {
|
|
315
323
|
systemConsumers.push(createSseBroadcastEventConsumer(sseBroker));
|
|
@@ -391,6 +399,7 @@ export function buildServer(options) {
|
|
|
391
399
|
...(mspOwner && { callerFeature: mspOwner }),
|
|
392
400
|
...(mspFiles && { files: mspFiles }),
|
|
393
401
|
...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
|
|
402
|
+
...(mspJobs && { jobs: mspJobs }),
|
|
394
403
|
...(mspFiles && {
|
|
395
404
|
derivatives: createDerivativesContext({
|
|
396
405
|
files: mspFiles,
|
|
@@ -665,15 +674,22 @@ export function buildServer(options) {
|
|
|
665
674
|
// not the whole app's schema) and cached per role key. Tenant is
|
|
666
675
|
// deliberately NOT part of the key: the projection depends only on
|
|
667
676
|
// roles, and per-tenant caching would grow unboundedly across tenants
|
|
668
|
-
// that share the same role set.
|
|
677
|
+
// that share the same role set. What IS part of the key: the sorted set of
|
|
678
|
+
// features the caller's tenant has switched off (r.toggleable) — few
|
|
679
|
+
// distinct sets, so the cache stays small. The resolver is read per request
|
|
680
|
+
// because run{Dev,Prod}App fill it in after the server is built.
|
|
669
681
|
let fullSchema;
|
|
670
682
|
const projectedSchemaCache = new Map();
|
|
671
|
-
app.get(`/api${Routes.schema}`, (c) => {
|
|
683
|
+
app.get(`/api${Routes.schema}`, async (c) => {
|
|
672
684
|
const user = getUser(c);
|
|
673
685
|
if (isMissingOrAnonymousUser(user)) {
|
|
674
686
|
return unauthenticatedResponse(c);
|
|
675
687
|
}
|
|
676
|
-
const
|
|
688
|
+
const effectiveFeatures = options.dispatcherOptions?.effectiveFeatures;
|
|
689
|
+
const disabledFeatureNames = effectiveFeatures === undefined
|
|
690
|
+
? []
|
|
691
|
+
: await resolveDisabledFeatureNames(options.registry, effectiveFeatures, user.tenantId);
|
|
692
|
+
const roleKey = JSON.stringify([[...new Set(user?.roles ?? [])].sort(), disabledFeatureNames]);
|
|
677
693
|
let cached = projectedSchemaCache.get(roleKey);
|
|
678
694
|
if (cached === undefined) {
|
|
679
695
|
if (fullSchema === undefined) {
|
|
@@ -681,7 +697,7 @@ export function buildServer(options) {
|
|
|
681
697
|
searchAdapterMissing: !options.context.searchAdapter,
|
|
682
698
|
});
|
|
683
699
|
}
|
|
684
|
-
const projected = projectAppSchemaForRoles(fullSchema, user?.roles ?? []);
|
|
700
|
+
const projected = projectAppSchemaForRoles(stripDisabledFeaturesFromAppSchema(fullSchema, options.registry, disabledFeatureNames), user?.roles ?? []);
|
|
685
701
|
const json = JSON.stringify(projected);
|
|
686
702
|
cached = { json, etag: computeStrongEtag(json) };
|
|
687
703
|
projectedSchemaCache.set(roleKey, cached);
|
package/dist/bun-db/query.js
CHANGED
|
@@ -861,6 +861,21 @@ export async function unsafeReadRetrying(db, sqlText, params) {
|
|
|
861
861
|
throw lastErr;
|
|
862
862
|
}
|
|
863
863
|
}
|
|
864
|
+
function selectColumnList(info, fields) {
|
|
865
|
+
if (fields === undefined)
|
|
866
|
+
return "*";
|
|
867
|
+
if (fields.length === 0) {
|
|
868
|
+
throw new Error(`selectMany: fields must not be empty for "${info.name}"`);
|
|
869
|
+
}
|
|
870
|
+
return fields
|
|
871
|
+
.map((field) => {
|
|
872
|
+
if (!info.hasColumn(field)) {
|
|
873
|
+
throw new Error(`selectMany: fields entry "${field}" is not a column of "${info.name}"`);
|
|
874
|
+
}
|
|
875
|
+
return quoteIdent(info.columnOf(field));
|
|
876
|
+
})
|
|
877
|
+
.join(", ");
|
|
878
|
+
}
|
|
864
879
|
// biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
|
|
865
880
|
export async function selectMany(db, table, where, options) {
|
|
866
881
|
const scoped = tenantDbDelegate(db);
|
|
@@ -868,7 +883,7 @@ export async function selectMany(db, table, where, options) {
|
|
|
868
883
|
return scoped.selectMany(table, where, options);
|
|
869
884
|
}
|
|
870
885
|
const info = extractTableInfo(table);
|
|
871
|
-
let sqlText = `SELECT
|
|
886
|
+
let sqlText = `SELECT ${selectColumnList(info, options?.fields)} FROM ${quoteIdent(info.name)}`;
|
|
872
887
|
let values = [];
|
|
873
888
|
if (where && Object.keys(where).length > 0) {
|
|
874
889
|
const w = buildWhereClause(info, where, 1);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdToKey, } from "./kms-adapter.js";
|
|
3
3
|
// Non-persistent adapter for tests and dev mode. Erased entries stay as
|
|
4
|
-
// tombstones so the create-after-erase
|
|
4
|
+
// tombstones (also for never-keyed subjects) so the create-after-erase
|
|
5
|
+
// contract holds within a process.
|
|
5
6
|
export class InMemoryKmsAdapter {
|
|
6
7
|
capabilities = { mode: "local-key" };
|
|
7
8
|
keys = new Map();
|
|
@@ -20,12 +21,8 @@ export class InMemoryKmsAdapter {
|
|
|
20
21
|
return entry.key;
|
|
21
22
|
}
|
|
22
23
|
async eraseKey(subject) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!entry)
|
|
26
|
-
return;
|
|
27
|
-
entry.key = null;
|
|
28
|
-
entry.erased = true;
|
|
24
|
+
// A never-keyed subject gets a tombstone too, so createKey cannot mint one later.
|
|
25
|
+
this.keys.set(subjectIdToKey(subject), { key: null, erased: true });
|
|
29
26
|
}
|
|
30
27
|
async health() {
|
|
31
28
|
return { ok: true, latencyMs: 0 };
|
|
@@ -107,15 +107,19 @@ export class PgKmsAdapter {
|
|
|
107
107
|
}
|
|
108
108
|
async eraseKey(subject, ctx) {
|
|
109
109
|
await this.ensureSchema();
|
|
110
|
-
//
|
|
111
|
-
//
|
|
110
|
+
// Upsert: a never-keyed subject (pre-KMS plaintext) still gets a tombstone,
|
|
111
|
+
// so a later createKey cannot mint a fresh key and re-encrypt the plaintext.
|
|
112
|
+
// The erased_at IS NULL guard keeps repeat erases from overwriting the
|
|
113
|
+
// original tombstone audit fields.
|
|
112
114
|
await this.sql `
|
|
113
|
-
|
|
115
|
+
INSERT INTO kumiko_subject_keys (subject_id, cipher_key, kek_version, erased_at, erased_by, erase_reason)
|
|
116
|
+
VALUES (${subjectIdToKey(subject)}, NULL, ${this.kekVersion}, now(), ${ctx.userId ?? null}, ${ctx.eraseReason ?? null})
|
|
117
|
+
ON CONFLICT (subject_id) DO UPDATE
|
|
114
118
|
SET cipher_key = NULL,
|
|
115
119
|
erased_at = now(),
|
|
116
|
-
erased_by =
|
|
117
|
-
erase_reason =
|
|
118
|
-
WHERE
|
|
120
|
+
erased_by = EXCLUDED.erased_by,
|
|
121
|
+
erase_reason = EXCLUDED.erase_reason
|
|
122
|
+
WHERE kumiko_subject_keys.erased_at IS NULL`;
|
|
119
123
|
}
|
|
120
124
|
async health() {
|
|
121
125
|
const start = Date.now();
|
|
@@ -358,8 +358,8 @@ export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
|
358
358
|
function sqlExpressionText(where) {
|
|
359
359
|
return isSqlExpression(where) ? where.text : undefined;
|
|
360
360
|
}
|
|
361
|
-
// Validates that a backing Drizzle table (declared via `r.entity(name, def,
|
|
362
|
-
//
|
|
361
|
+
// Validates that a backing Drizzle table (declared via `r.entity({ name, ...def,
|
|
362
|
+
// backingTable })`) is a SUPERSET of the field-derived meta: every column the
|
|
363
363
|
// entity fields produce must exist on the table with the same pgType +
|
|
364
364
|
// notNull. Ride-along columns/indexes the table adds on top (envelope,
|
|
365
365
|
// uniqueIndex, …) are exactly the point — they pass. A field with no matching
|
|
@@ -371,13 +371,13 @@ export function assertBackingTableSuperset(entityName, fieldMeta, tableMeta) {
|
|
|
371
371
|
for (const fieldCol of fieldMeta.columns) {
|
|
372
372
|
const tableCol = tableCols.get(fieldCol.name);
|
|
373
373
|
if (!tableCol) {
|
|
374
|
-
throw new Error(`r.entity("${entityName}", …,
|
|
374
|
+
throw new Error(`r.entity({ name: "${entityName}", …, backingTable }): the backing table ` +
|
|
375
375
|
`"${tableMeta.tableName}" is missing column "${fieldCol.name}" that the ` +
|
|
376
376
|
"entity field declares. The table must be a superset of the entity's " +
|
|
377
377
|
"fields — add the column to the table or remove the field.");
|
|
378
378
|
}
|
|
379
379
|
if (tableCol.pgType !== fieldCol.pgType || tableCol.notNull !== fieldCol.notNull) {
|
|
380
|
-
throw new Error(`r.entity("${entityName}", …,
|
|
380
|
+
throw new Error(`r.entity({ name: "${entityName}", …, backingTable }): column "${fieldCol.name}" ` +
|
|
381
381
|
`disagrees between entity field (${fieldCol.pgType}, ` +
|
|
382
382
|
`notNull=${fieldCol.notNull}) and backing table "${tableMeta.tableName}" ` +
|
|
383
383
|
`(${tableCol.pgType}, notNull=${tableCol.notNull}). Align them.`);
|
|
@@ -169,7 +169,7 @@ export function createWriteVerbs(ctx) {
|
|
|
169
169
|
failure: writeFailure(new FrameworkVersionConflict({
|
|
170
170
|
entityId: id,
|
|
171
171
|
expectedVersion: currentVersion,
|
|
172
|
-
currentVersion:
|
|
172
|
+
currentVersion: await versionAfterConflict(runner, String(id), streamTenantFor(user, streamTenantId)),
|
|
173
173
|
})),
|
|
174
174
|
};
|
|
175
175
|
}
|
|
@@ -181,6 +181,16 @@ export function createWriteVerbs(ctx) {
|
|
|
181
181
|
throw e;
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
// After a lost append race the stream version read before the append is stale;
|
|
185
|
+
// report the actual one. -1 when the lookup itself fails.
|
|
186
|
+
async function versionAfterConflict(runner, aggregateId, tenantId) {
|
|
187
|
+
try {
|
|
188
|
+
return await getStreamVersion(runner, aggregateId, tenantId);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return -1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
184
194
|
async function expectedStreamVersion(db, id, streamTenant, expect) {
|
|
185
195
|
const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
|
|
186
196
|
const row = snapshot.row;
|
|
@@ -298,13 +308,7 @@ export function createWriteVerbs(ctx) {
|
|
|
298
308
|
}
|
|
299
309
|
catch (e) {
|
|
300
310
|
if (e instanceof EventStoreVersionConflict) {
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user, streamTenantId));
|
|
304
|
-
}
|
|
305
|
-
catch {
|
|
306
|
-
// Lookup failure — keep the sentinel.
|
|
307
|
-
}
|
|
311
|
+
const currentVersion = await versionAfterConflict(runner, aggregateId, streamTenantFor(user, streamTenantId));
|
|
308
312
|
return writeFailure(new FrameworkVersionConflict({
|
|
309
313
|
entityId: aggregateId,
|
|
310
314
|
expectedVersion: 0,
|
|
@@ -603,7 +607,7 @@ export function createWriteVerbs(ctx) {
|
|
|
603
607
|
return writeFailure(new FrameworkVersionConflict({
|
|
604
608
|
entityId: payload.id,
|
|
605
609
|
expectedVersion: payload.version ?? 0,
|
|
606
|
-
currentVersion,
|
|
610
|
+
currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenant),
|
|
607
611
|
}));
|
|
608
612
|
}
|
|
609
613
|
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
@@ -675,7 +679,7 @@ export function createWriteVerbs(ctx) {
|
|
|
675
679
|
return writeFailure(new FrameworkVersionConflict({
|
|
676
680
|
entityId: payload.id,
|
|
677
681
|
expectedVersion: currentVersion,
|
|
678
|
-
currentVersion:
|
|
682
|
+
currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
|
|
679
683
|
}));
|
|
680
684
|
}
|
|
681
685
|
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
@@ -760,7 +764,7 @@ export function createWriteVerbs(ctx) {
|
|
|
760
764
|
return writeFailure(new FrameworkVersionConflict({
|
|
761
765
|
entityId: payload.id,
|
|
762
766
|
expectedVersion: currentVersion,
|
|
763
|
-
currentVersion:
|
|
767
|
+
currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
|
|
764
768
|
}));
|
|
765
769
|
}
|
|
766
770
|
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
@@ -825,7 +829,7 @@ export function createWriteVerbs(ctx) {
|
|
|
825
829
|
// table. Rows predating the subject-KMS rollout may still hold plaintext
|
|
826
830
|
// pii, and envelope encryption is not idempotent: decrypt, then
|
|
827
831
|
// re-encrypt as delete does.
|
|
828
|
-
const previousPlain = await decryptForRead(data);
|
|
832
|
+
const previousPlain = await decryptForRead(rehydrateCompoundTypes(data, entity));
|
|
829
833
|
const appended = await appendRestoredEvent(runner, user, payload.id, previousPlain, streamTenantId, currentVersion);
|
|
830
834
|
if ("failure" in appended)
|
|
831
835
|
return appended.failure;
|
|
@@ -25,7 +25,7 @@ export function validateApiExposureMatching(feature, allExposedApis, featureMap)
|
|
|
25
25
|
}
|
|
26
26
|
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
27
27
|
if (!allDeps.includes(providerFeature)) {
|
|
28
|
-
throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires("${providerFeature}").`);
|
|
28
|
+
throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`);
|
|
29
29
|
}
|
|
30
30
|
// Sanity: provider feature actually exists in this app's feature set.
|
|
31
31
|
// Should always be true if allExposedApis was built from `features`,
|
|
@@ -48,7 +48,7 @@ export function validateExtensionUsages(feature, extensionProviders) {
|
|
|
48
48
|
}
|
|
49
49
|
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
50
50
|
if (!allDeps.includes(providerFeature)) {
|
|
51
|
-
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires("${providerFeature}").`);
|
|
51
|
+
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -2,7 +2,7 @@ import { optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
|
2
2
|
import { isEncryptedAtRest } from "../config-helpers.js";
|
|
3
3
|
// --- Toggleable-dependency warnings ---
|
|
4
4
|
//
|
|
5
|
-
// When feature A declares r.requires("B") and B is toggleable with
|
|
5
|
+
// When feature A declares r.requires({ features: ["B"] }) and B is toggleable with
|
|
6
6
|
// default=false, A is effectively disabled out-of-the-box until someone
|
|
7
7
|
// flips B on globally. That's usually an oversight — the dev either meant
|
|
8
8
|
// optionalRequires, or forgot to ship B with default=true. We warn (not
|
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readHandlerFieldReferences } from "../handler-field-references.js";
|
|
2
2
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
3
|
-
import { getZodObjectShape } from "./zod-shape.js";
|
|
4
3
|
function validateHandlerGroup(feature, featureMap, handlers, handlerNoun) {
|
|
5
4
|
for (const [handlerName, def] of Object.entries(handlers)) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
continue;
|
|
9
|
-
for (const fieldName of Object.keys(shape)) {
|
|
10
|
-
const where = `Input field "${fieldName}" of ${handlerNoun} "${handlerName}"`;
|
|
11
|
-
const reading = readHandlerFieldReference(def.schema, fieldName);
|
|
12
|
-
if (reading.kind === "none")
|
|
13
|
-
continue;
|
|
5
|
+
for (const { path, reading } of readHandlerFieldReferences(def.schema)) {
|
|
6
|
+
const where = `Input field "${path}" of ${handlerNoun} "${handlerName}"`;
|
|
14
7
|
if (reading.kind === "invalid") {
|
|
15
8
|
throw new Error(`[Feature ${feature.name}] ${where} has an invalid meta references value ${JSON.stringify(reading.raw)}. ` +
|
|
16
9
|
`Use "<entity>", "<feature>:<entity>" or { entity, by? }.`);
|
|
@@ -2216,7 +2216,7 @@ function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureNam
|
|
|
2216
2216
|
const targetFeature = featureMap.get(target.featureName);
|
|
2217
2217
|
if (targetFeature === undefined) {
|
|
2218
2218
|
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
|
|
2219
|
-
`mounted — mount it and declare r.requires("${target.featureName}").`);
|
|
2219
|
+
`mounted — mount it and declare r.requires({ features: ["${target.featureName}"] }).`);
|
|
2220
2220
|
}
|
|
2221
2221
|
if (targetFeature.entities?.[target.entityName] === undefined) {
|
|
2222
2222
|
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
|
|
@@ -20,12 +20,9 @@
|
|
|
20
20
|
// Translations: verbatim feature.translations (raw r.translations keys,
|
|
21
21
|
// unfiltered — see FeatureSchema.translations doc)
|
|
22
22
|
//
|
|
23
|
-
// Feature
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// dispatcher, nicht in der Registry, und wir haben hier keinen TenantDb-
|
|
27
|
-
// Kontext um sie zu lesen. TODO wenn das ein realer Use-Case wird:
|
|
28
|
-
// `effectiveFeatures` Argument annehmen und über alle iterations filtern.
|
|
23
|
+
// Feature toggles: this schema is tenant-independent and lists every feature.
|
|
24
|
+
// The schema route strips features the caller's tenant has switched off
|
|
25
|
+
// (r.toggleable) via stripDisabledFeaturesFromAppSchema.
|
|
29
26
|
import { ZodObject } from "zod";
|
|
30
27
|
import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields.js";
|
|
31
28
|
import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "./build-config-feature-schema.js";
|