@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
package/AGENTS.md
CHANGED
|
@@ -49,7 +49,10 @@ write the table yourself.
|
|
|
49
49
|
**Handler.** The only way in. A write handler changes state, a query handler
|
|
50
50
|
reads it. Both have a qualified name (QN), a Zod payload schema and an access
|
|
51
51
|
rule. `r.crud(name, entity, options)` registers the entity plus create,
|
|
52
|
-
update, delete, restore, list and detail handlers in one call.
|
|
52
|
+
update, delete, restore, list and detail handlers in one call. Its options
|
|
53
|
+
need an access rule for both sides, `write: { access }` and
|
|
54
|
+
`read: { access }`; `verbAccess: { delete: { roles: ["Admin"] } }`
|
|
55
|
+
overrides a single verb. Without them boot stops. Turn single
|
|
53
56
|
verbs off with `verbs: { delete: false }` and register them yourself when
|
|
54
57
|
they need different access or logic. Custom logic goes into
|
|
55
58
|
`r.writeHandler(qn, schema, fn, { access })` and
|
|
@@ -60,7 +63,10 @@ executor from `createEntityExecutor(name, entity)`.
|
|
|
60
63
|
`entityList`, `entityEdit`, `projectionList`, `projectionDetail`,
|
|
61
64
|
`actionForm`, `dashboard` and `custom`. The renderer builds the UI from the
|
|
62
65
|
declaration. Columns, sections, row actions, tabs, wizard steps and
|
|
63
|
-
conditional fields are data, not JSX.
|
|
66
|
+
conditional fields are data, not JSX. A `dashboard` lists `panels`; a
|
|
67
|
+
`stat` panel names a query handler and the `valueField` to show from its
|
|
68
|
+
result, and a `stat-group` panel holds several stats. The query returns one
|
|
69
|
+
flat object with those fields.
|
|
64
70
|
|
|
65
71
|
**Nav.** A sidebar entry: `r.nav({ id, label, screen, parent, order })`.
|
|
66
72
|
Parents may belong to another feature; refer to them by QN.
|
|
@@ -83,6 +89,12 @@ that holds global data calls `r.systemScope()`.
|
|
|
83
89
|
Screens and nav entries can narrow visibility further with their own
|
|
84
90
|
`access`. Anonymous endpoints use the `anonymous` role. Field level and row
|
|
85
91
|
level rules exist as well (see the table below).
|
|
92
|
+
An `openToAll` write handler whose payload contains personal fields must
|
|
93
|
+
say that every tenant member may write them:
|
|
94
|
+
`{ openToAll: { reason: "...", personalData: "tenant-members" } }`. The
|
|
95
|
+
alternatives are roles, or binding each row to its caller. Boot names all
|
|
96
|
+
three. On query handlers `personalData` is only valid as
|
|
97
|
+
`access: { roles: ["anonymous"], personalData: "public-intake" }`.
|
|
86
98
|
|
|
87
99
|
**Personal data.** Every text field says whether it holds personal data;
|
|
88
100
|
the field types reject a text field without that stance.
|
|
@@ -90,7 +102,10 @@ the field types reject a text field without that stance.
|
|
|
90
102
|
business data. For personal data name the subject, `personal: "self"`,
|
|
91
103
|
`personal: { of: "<ownerField>" }` or `personal: "tenant"`, plus
|
|
92
104
|
`find: "exact" | "fuzzy" | "none" | "secret"`. A plain foreign key to a user
|
|
93
|
-
is `personal: "ref"`.
|
|
105
|
+
is `personal: "ref"`. `personal: "self"` is for the row that is the user.
|
|
106
|
+
When the record itself is the person, such as a contact or a customer that
|
|
107
|
+
is not a user, use `personal: { of: "id" }`; the entity then needs a uuid
|
|
108
|
+
`id`. The framework encrypts personal fields per subject and
|
|
94
109
|
can erase them on request.
|
|
95
110
|
|
|
96
111
|
**Client.** The web side of a feature is a `ClientFeatureDefinition` in the
|
|
@@ -145,9 +160,15 @@ feature and test. Start from them.
|
|
|
145
160
|
| Unit tests | `bun run test` |
|
|
146
161
|
| Integration tests (needs `docker compose up -d`) | `bun run test:integration` |
|
|
147
162
|
| Browser tests | `bun run e2e` |
|
|
148
|
-
| New migration after an entity change | `bun run schema:generate
|
|
163
|
+
| New migration after an entity change or a newly mounted bundled feature | `bun run schema:generate <name>` |
|
|
164
|
+
| Check migrations against the features, no database needed | `bun kumiko-schema validate` |
|
|
149
165
|
| Apply migrations | `bun run schema:apply` |
|
|
150
166
|
|
|
167
|
+
`<name>` is required and becomes part of the file name
|
|
168
|
+
(`schema:generate add-invoice`). Mounting a bundled feature in
|
|
169
|
+
`APP_FEATURES` adds its tables, so it needs a migration like your own
|
|
170
|
+
entities do.
|
|
171
|
+
|
|
151
172
|
## Rules: never this, do that
|
|
152
173
|
|
|
153
174
|
These rules are enforced by boot validation, guards or review. Breaking them
|
|
@@ -164,6 +185,11 @@ produces code that compiles and then fails in production.
|
|
|
164
185
|
- Never hand-write SQL for reads that an executor `list` or `detail` covers.
|
|
165
186
|
Use `r.queryHandler` with the executor, or a projection with
|
|
166
187
|
`ctx.queryProjection`.
|
|
188
|
+
- Never filter or count the rows of one `list` page in code. `list` returns
|
|
189
|
+
one page (default 50, `limit` at most 200). Pass
|
|
190
|
+
`filter: { field, op, value }` (`eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`)
|
|
191
|
+
so the database filters, and `totalCount: true` to get `total` over all
|
|
192
|
+
matching rows; `limit: 1, totalCount: true` is a count query.
|
|
167
193
|
- Never change an event schema in place once it has been emitted. Version it
|
|
168
194
|
(see `agents/examples/event-sourcing.md`).
|
|
169
195
|
- Never read or write another tenant's data. `ctx.db` is tenant scoped on
|
|
@@ -237,6 +263,8 @@ as bundled features.
|
|
|
237
263
|
| --- | --- | --- |
|
|
238
264
|
| A CRUD entity with list and edit | `createEntity` + `r.crud` + `entityList` / `entityEdit` screens | `agents/examples/basic-entity.md` |
|
|
239
265
|
| Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
|
|
266
|
+
| Filtered lists and counts | Executor `list` with `filter` and `totalCount` | `agents/examples/custom-handlers.md` |
|
|
267
|
+
| Key figures on a dashboard | `dashboard` screen with `stat` / `stat-group` panels and a query handler | `agents/examples/dashboard-stats.md` |
|
|
240
268
|
| Relations between entities | `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
|
|
241
269
|
| Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
|
|
242
270
|
| List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
|
|
@@ -283,7 +311,10 @@ source file with its path. `src/feature.ts` holds the feature;
|
|
|
283
311
|
the framework repo set up their stack with `setupTestStack` from
|
|
284
312
|
`@cosmicdrift/kumiko-framework/stack` and create tables in the test. In an
|
|
285
313
|
app, `setupAppTestStack` and `seedTenant` do that for you; keep the
|
|
286
|
-
assertions, take the setup from your scaffolded integration test.
|
|
314
|
+
assertions, take the setup from your scaffolded integration test. Table
|
|
315
|
+
setup in sample tests (`unsafeCreateEntityTable`, `createEventsTable`) and
|
|
316
|
+
any raw `DELETE FROM` cleanup are framework internals. Never copy them;
|
|
317
|
+
isolate tests with one tenant each.
|
|
287
318
|
|
|
288
319
|
Some samples import from `@app/define`. That alias points at the generated
|
|
289
320
|
`.kumiko/define.ts`, which re-exports the engine with event types narrowed to
|
|
@@ -39,7 +39,6 @@ bun test
|
|
|
39
39
|
|
|
40
40
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
41
41
|
import { setTenantCookie } from "@cosmicdrift/kumiko-framework/api";
|
|
42
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
43
42
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
43
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
45
44
|
import {
|
|
@@ -48,7 +47,7 @@ import {
|
|
|
48
47
|
TestUsers,
|
|
49
48
|
unsafeCreateEntityTable,
|
|
50
49
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
51
|
-
import { multiTenantShopFeature, productEntity
|
|
50
|
+
import { multiTenantShopFeature, productEntity } from "../feature";
|
|
52
51
|
import { createSubdomainResolver, extractSubdomain } from "../subdomain-resolver";
|
|
53
52
|
|
|
54
53
|
const ACME_TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
@@ -61,6 +60,9 @@ const KNOWN_TENANTS: Record<string, TenantId> = {
|
|
|
61
60
|
globex: GLOBEX_TENANT_ID,
|
|
62
61
|
};
|
|
63
62
|
|
|
63
|
+
// Unique value: the acme tenant is fixed, so the row is told apart by name, not by reset.
|
|
64
|
+
const ACME_PRODUCT_NAME = "Acme Anvil (subdomain read)";
|
|
65
|
+
|
|
64
66
|
let stack: TestStack;
|
|
65
67
|
let resolver: ReturnType<typeof createSubdomainResolver>;
|
|
66
68
|
// Counters prove that the cache actually hits — one DB lookup per
|
|
@@ -110,8 +112,7 @@ afterAll(async () => {
|
|
|
110
112
|
await stack?.cleanup();
|
|
111
113
|
});
|
|
112
114
|
|
|
113
|
-
beforeEach(
|
|
114
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
|
|
115
|
+
beforeEach(() => {
|
|
115
116
|
resolver.invalidateAll();
|
|
116
117
|
lookupCount = 0;
|
|
117
118
|
existsCount = 0;
|
|
@@ -123,7 +124,7 @@ describe("subdomain resolution", () => {
|
|
|
123
124
|
// visitors who land on acme.shop.com.
|
|
124
125
|
await stack.http.writeOk(
|
|
125
126
|
"mtshop:write:product:create",
|
|
126
|
-
{ name:
|
|
127
|
+
{ name: ACME_PRODUCT_NAME },
|
|
127
128
|
{ ...TestUsers.admin, tenantId: ACME_TENANT_ID },
|
|
128
129
|
);
|
|
129
130
|
|
|
@@ -136,7 +137,9 @@ describe("subdomain resolution", () => {
|
|
|
136
137
|
|
|
137
138
|
expect(res.status).toBe(200);
|
|
138
139
|
const body = (await res.json()) as { data: Array<{ name: string }> };
|
|
139
|
-
expect(body.data.map((p) => p.name)).toEqual([
|
|
140
|
+
expect(body.data.map((p) => p.name).filter((name) => name === ACME_PRODUCT_NAME)).toEqual([
|
|
141
|
+
ACME_PRODUCT_NAME,
|
|
142
|
+
]);
|
|
140
143
|
});
|
|
141
144
|
|
|
142
145
|
test("unknown subdomain → 400 tenant_required (resolver returned null)", async () => {
|
|
@@ -41,8 +41,8 @@ bun test
|
|
|
41
41
|
// that parses the host (acme.shop.com → tenantId) plus a tenantExists
|
|
42
42
|
// callback that confirms the tenant is real (against DB or cache).
|
|
43
43
|
|
|
44
|
-
import { afterAll, beforeAll,
|
|
45
|
-
import {
|
|
44
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
45
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
46
46
|
import { ANONYMOUS_USER_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
47
47
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
48
48
|
import {
|
|
@@ -56,11 +56,14 @@ import {
|
|
|
56
56
|
guestOrderEntity,
|
|
57
57
|
guestOrderTable,
|
|
58
58
|
productEntity,
|
|
59
|
-
productTable,
|
|
60
59
|
} from "../feature";
|
|
61
60
|
|
|
62
61
|
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
63
62
|
|
|
63
|
+
// Unique per test: the tenant is fixed, so rows are told apart by value, not by reset.
|
|
64
|
+
const PRODUCT_NAME = "Espresso Beans (anonymous read)";
|
|
65
|
+
const GUEST_EMAIL = "guest-checkout@example.com";
|
|
66
|
+
|
|
64
67
|
let stack: TestStack;
|
|
65
68
|
|
|
66
69
|
beforeAll(async () => {
|
|
@@ -79,16 +82,11 @@ afterAll(async () => {
|
|
|
79
82
|
await stack?.cleanup();
|
|
80
83
|
});
|
|
81
84
|
|
|
82
|
-
beforeEach(async () => {
|
|
83
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
|
|
84
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${guestOrderTable.tableName}"`);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
85
|
describe("public read: anonymous + authenticated share the listing", () => {
|
|
88
86
|
test("anonymous caller sees products without any auth headers", async () => {
|
|
89
87
|
await stack.http.writeOk(
|
|
90
88
|
"shop:write:product:create",
|
|
91
|
-
{ name:
|
|
89
|
+
{ name: PRODUCT_NAME, priceCents: "1500" },
|
|
92
90
|
TestUsers.admin,
|
|
93
91
|
);
|
|
94
92
|
|
|
@@ -99,17 +97,11 @@ describe("public read: anonymous + authenticated share the listing", () => {
|
|
|
99
97
|
|
|
100
98
|
expect(res.status).toBe(200);
|
|
101
99
|
const body = (await res.json()) as { data: Array<{ name: string }> };
|
|
102
|
-
|
|
103
|
-
expect(
|
|
100
|
+
const mine = body.data.filter((product) => product.name === PRODUCT_NAME);
|
|
101
|
+
expect(mine).toHaveLength(1);
|
|
104
102
|
});
|
|
105
103
|
|
|
106
104
|
test("authenticated user reaches the same handler via JWT", async () => {
|
|
107
|
-
await stack.http.writeOk(
|
|
108
|
-
"shop:write:product:create",
|
|
109
|
-
{ name: "Espresso Beans", priceCents: "1500" },
|
|
110
|
-
TestUsers.admin,
|
|
111
|
-
);
|
|
112
|
-
|
|
113
105
|
const res = await stack.http.query("shop:query:product:list", {}, TestUsers.user);
|
|
114
106
|
expect(res.status).toBe(200);
|
|
115
107
|
});
|
|
@@ -117,26 +109,22 @@ describe("public read: anonymous + authenticated share the listing", () => {
|
|
|
117
109
|
|
|
118
110
|
describe("guest checkout: anonymous write lands with placedBy=anonymous", () => {
|
|
119
111
|
test("anonymous order is stored and tagged with the anonymous user-id", async () => {
|
|
120
|
-
await stack.http.writeOk(
|
|
121
|
-
"shop:write:product:create",
|
|
122
|
-
{ name: "Espresso Beans", priceCents: "1500" },
|
|
123
|
-
TestUsers.admin,
|
|
124
|
-
);
|
|
125
|
-
|
|
126
112
|
const res = await stack.http.raw("POST", "/api/write", {
|
|
127
113
|
type: "shop:write:guest-order:place",
|
|
128
114
|
payload: {
|
|
129
115
|
productId: "00000000-0000-4000-8000-000000000099",
|
|
130
|
-
email:
|
|
116
|
+
email: GUEST_EMAIL,
|
|
131
117
|
},
|
|
132
118
|
});
|
|
133
119
|
|
|
134
120
|
expect(res.status).toBe(200);
|
|
135
121
|
|
|
136
|
-
const orders = await selectMany(stack.db, guestOrderTable)
|
|
122
|
+
const orders = (await selectMany(stack.db, guestOrderTable)).filter(
|
|
123
|
+
(order) => order.email === GUEST_EMAIL,
|
|
124
|
+
);
|
|
137
125
|
expect(orders).toHaveLength(1);
|
|
138
126
|
expect(orders[0]?.placedBy).toBe(ANONYMOUS_USER_ID);
|
|
139
|
-
expect(orders[0]?.email).toBe(
|
|
127
|
+
expect(orders[0]?.email).toBe(GUEST_EMAIL);
|
|
140
128
|
});
|
|
141
129
|
});
|
|
142
130
|
|
|
@@ -18,7 +18,7 @@ The production wiring shape: mount `createAuthMfaFeature()` next to `createAuthE
|
|
|
18
18
|
|
|
19
19
|
1. `createAuthMfaFeature({ setupTokenSecret, challengeTokenSecret, issuer })` builds the feature; `mfaStatusCheckerFromFeature(mfaFeature)` reads its login-status callback back off.
|
|
20
20
|
2. That callback goes into `createAuthEmailPasswordFeature({ mfaStatusChecker: ... })` — `login.write.ts` calls it after the password check to decide whether to mint a JWT or a challenge.
|
|
21
|
-
3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify } })` mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
|
|
21
|
+
3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify, switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate } })` (the gate is required with `mfaVerifyHandler`, so a tenant switch asks for the second factor too) mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
|
|
22
22
|
4. If `sessions` is mounted too, `bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers)` wires "enable/disable/regenerate signs out every other session."
|
|
23
23
|
|
|
24
24
|
## What to copy into your app
|
|
@@ -51,6 +51,7 @@ const server = buildServer({
|
|
|
51
51
|
membershipQuery: "tenant:query:memberships",
|
|
52
52
|
loginHandler: "auth-email-password:[REDACTED:API key param]",
|
|
53
53
|
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
54
|
+
switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
54
55
|
sessionCreator: sessionCallbacks.sessionCreator,
|
|
55
56
|
sessionRevoker: sessionCallbacks.sessionRevoker,
|
|
56
57
|
sessionChecker: sessionCallbacks.sessionChecker,
|
|
@@ -86,7 +87,7 @@ Apps built via `runDevApp`/`runProdApp` (the dev-server bootstrap used by `creat
|
|
|
86
87
|
// the resulting app behaves like this test asserts: login is two-step once
|
|
87
88
|
// MFA is enabled, and a valid TOTP code completes it.
|
|
88
89
|
|
|
89
|
-
import { afterAll, beforeAll,
|
|
90
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
90
91
|
import {
|
|
91
92
|
AuthHandlers,
|
|
92
93
|
createAuthEmailPasswordFeature,
|
|
@@ -106,9 +107,7 @@ import {
|
|
|
106
107
|
createUserFeature,
|
|
107
108
|
UserHandlers,
|
|
108
109
|
userEntity,
|
|
109
|
-
userTable,
|
|
110
110
|
} from "@cosmicdrift/kumiko-bundled-features/user";
|
|
111
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
112
111
|
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
113
112
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
114
113
|
import {
|
|
@@ -158,6 +157,7 @@ beforeAll(async () => {
|
|
|
158
157
|
membershipQuery: "tenant:query:memberships",
|
|
159
158
|
loginHandler: AuthHandlers.login,
|
|
160
159
|
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
160
|
+
switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
161
161
|
},
|
|
162
162
|
});
|
|
163
163
|
|
|
@@ -172,11 +172,6 @@ afterAll(async () => {
|
|
|
172
172
|
configureEntityFieldEncryption(undefined);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
beforeEach(async () => {
|
|
176
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
177
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
175
|
describe("auth-mfa sample wiring", () => {
|
|
181
176
|
test("login without MFA enrolled returns a JWT directly", async () => {
|
|
182
177
|
const hash = await hashPassword("super-long-password");
|
|
@@ -287,6 +282,7 @@ describe("auth-mfa sample wiring", () => {
|
|
|
287
282
|
// membershipQuery: "tenant:query:memberships",
|
|
288
283
|
// loginHandler: "auth-email-password:[REDACTED:API key param]",
|
|
289
284
|
// mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
285
|
+
// switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
290
286
|
// sessionCreator: sessionCallbacks.sessionCreator,
|
|
291
287
|
// sessionRevoker: sessionCallbacks.sessionRevoker,
|
|
292
288
|
// sessionChecker: sessionCallbacks.sessionChecker,
|
|
@@ -113,17 +113,19 @@ import {
|
|
|
113
113
|
createCustomFieldsFeature,
|
|
114
114
|
fieldDefinitionEntity,
|
|
115
115
|
} from "@cosmicdrift/kumiko-bundled-features/custom-fields";
|
|
116
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
117
116
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
118
117
|
import {
|
|
119
118
|
createTestUser,
|
|
120
119
|
setupTestStack,
|
|
121
120
|
type TestStack,
|
|
121
|
+
testTenantId,
|
|
122
122
|
unsafeCreateEntityTable,
|
|
123
123
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
124
124
|
import { propertyEntity, propertyFeature } from "../feature";
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
// Each test runs in its own tenant, so rows from other tests never show up.
|
|
127
|
+
let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
|
|
128
|
+
let nextTenant = 1;
|
|
127
129
|
const customFields = createCustomFieldsFeature();
|
|
128
130
|
|
|
129
131
|
let stack: TestStack;
|
|
@@ -139,10 +141,9 @@ afterAll(async () => {
|
|
|
139
141
|
await stack.cleanup();
|
|
140
142
|
});
|
|
141
143
|
|
|
142
|
-
beforeEach(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
nextTenant += 1;
|
|
146
|
+
admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
|
|
146
147
|
});
|
|
147
148
|
|
|
148
149
|
async function defineField(fieldKey: string, type = "text") {
|
|
@@ -30,9 +30,10 @@ logic is yours.
|
|
|
30
30
|
- **`skipOptimisticLock` for last-writer-wins** — `reset` deliberately
|
|
31
31
|
bypasses the version check because the operation is admin-driven and
|
|
32
32
|
resetting twice is semantically the same as resetting once.
|
|
33
|
-
- **Custom queries** — `counter:active`
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
- **Custom queries** — `counter:active` passes a server-side `filter`
|
|
34
|
+
(`eq`/`ne`/`lt`/`gt`/`lte`/`gte`/`in`) and `totalCount` to the executor's
|
|
35
|
+
list. Never filter a page in TypeScript: list is paginated, so rows after
|
|
36
|
+
the first page would be missed.
|
|
36
37
|
- **`failNotFound` helper** — typed error response for the 404 case
|
|
37
38
|
without a hand-built `KumikoError` subclass.
|
|
38
39
|
|
|
@@ -69,6 +70,7 @@ import {
|
|
|
69
70
|
setupTestStack,
|
|
70
71
|
type TestStack,
|
|
71
72
|
TestUsers,
|
|
73
|
+
testTenantId,
|
|
72
74
|
unsafeCreateEntityTable,
|
|
73
75
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
74
76
|
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -231,44 +233,80 @@ describe("custom write handler: reset", () => {
|
|
|
231
233
|
});
|
|
232
234
|
|
|
233
235
|
describe("custom query handler: active counters", () => {
|
|
236
|
+
// Own tenants: the total assertions would otherwise count counters created by other tests.
|
|
237
|
+
const activeAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(11) });
|
|
238
|
+
const activeUser = createTestUser({ id: 2, roles: ["User"], tenantId: testTenantId(11) });
|
|
239
|
+
const otherAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(12) });
|
|
240
|
+
|
|
234
241
|
test("filters counters by minimum count", async () => {
|
|
235
242
|
const c1 = await stack.http.writeOk<SaveContext>(
|
|
236
243
|
"counters:write:counter:create",
|
|
237
244
|
{ name: "active-a" },
|
|
238
|
-
|
|
245
|
+
activeAdmin,
|
|
239
246
|
);
|
|
240
247
|
const c2 = await stack.http.writeOk<SaveContext>(
|
|
241
248
|
"counters:write:counter:create",
|
|
242
249
|
{ name: "active-b" },
|
|
243
|
-
|
|
250
|
+
activeAdmin,
|
|
244
251
|
);
|
|
245
252
|
await stack.http.writeOk<SaveContext>(
|
|
246
253
|
"counters:write:counter:create",
|
|
247
254
|
{ name: "inactive" },
|
|
248
|
-
|
|
255
|
+
activeAdmin,
|
|
249
256
|
);
|
|
250
257
|
|
|
251
258
|
await stack.http.writeOk<SaveContext>(
|
|
252
259
|
"counters:write:counter:increment",
|
|
253
260
|
{ id: c1.id, amount: 10 },
|
|
254
|
-
|
|
261
|
+
activeUser,
|
|
255
262
|
);
|
|
256
263
|
await stack.http.writeOk<SaveContext>(
|
|
257
264
|
"counters:write:counter:increment",
|
|
258
265
|
{ id: c2.id, amount: 5 },
|
|
259
|
-
|
|
266
|
+
activeUser,
|
|
260
267
|
);
|
|
261
268
|
|
|
262
|
-
const result = await stack.http.queryOk<{
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
);
|
|
269
|
+
const result = await stack.http.queryOk<{
|
|
270
|
+
rows: Record<string, unknown>[];
|
|
271
|
+
nextCursor: string | null;
|
|
272
|
+
total?: number;
|
|
273
|
+
}>("counters:query:counter:active", { minCount: 5 }, activeAdmin);
|
|
267
274
|
|
|
268
275
|
const names = result.rows.map((r) => r["name"]);
|
|
269
276
|
expect(names).toContain("active-a");
|
|
270
277
|
expect(names).toContain("active-b");
|
|
271
278
|
expect(names).not.toContain("inactive");
|
|
279
|
+
expect(result.total).toBe(2);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("total and nextCursor describe the full filtered set", async () => {
|
|
283
|
+
for (const name of ["total-a", "total-b", "total-c"]) {
|
|
284
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
285
|
+
"counters:write:counter:create",
|
|
286
|
+
{ name },
|
|
287
|
+
otherAdmin,
|
|
288
|
+
);
|
|
289
|
+
await stack.http.writeOk<SaveContext>(
|
|
290
|
+
"counters:write:counter:increment",
|
|
291
|
+
{ id: created.id, amount: 1 },
|
|
292
|
+
otherAdmin,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
await stack.http.writeOk<SaveContext>(
|
|
296
|
+
"counters:write:counter:create",
|
|
297
|
+
{ name: "total-untouched" },
|
|
298
|
+
otherAdmin,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const result = await stack.http.queryOk<{
|
|
302
|
+
rows: Record<string, unknown>[];
|
|
303
|
+
nextCursor: string | null;
|
|
304
|
+
total?: number;
|
|
305
|
+
}>("counters:query:counter:active", { minCount: 1 }, otherAdmin);
|
|
306
|
+
|
|
307
|
+
expect(result.rows).toHaveLength(3);
|
|
308
|
+
expect(result.total).toBe(3);
|
|
309
|
+
expect(result.nextCursor).toBeNull();
|
|
272
310
|
});
|
|
273
311
|
});
|
|
274
312
|
```
|
|
@@ -277,7 +315,7 @@ describe("custom query handler: active counters", () => {
|
|
|
277
315
|
|
|
278
316
|
```ts
|
|
279
317
|
// Custom Handlers Sample
|
|
280
|
-
// Shows: writeHandler with business logic, queryHandler with
|
|
318
|
+
// Shows: writeHandler with business logic, queryHandler with a server-side filter,
|
|
281
319
|
// handler that modifies payload before DB write.
|
|
282
320
|
|
|
283
321
|
import {
|
|
@@ -290,6 +328,9 @@ import {
|
|
|
290
328
|
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
291
329
|
import { z } from "zod";
|
|
292
330
|
|
|
331
|
+
// detail() returns an untyped row; parse the two fields the increment needs once.
|
|
332
|
+
const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
|
|
333
|
+
|
|
293
334
|
export const counterEntity = createEntity({
|
|
294
335
|
table: "read_sample_counters",
|
|
295
336
|
fields: {
|
|
@@ -326,13 +367,14 @@ export const counterFeature = defineFeature("counters", (r) => {
|
|
|
326
367
|
return failNotFound("counter", event.payload.id);
|
|
327
368
|
}
|
|
328
369
|
|
|
329
|
-
const
|
|
370
|
+
const { count, version } = counterVersionRowSchema.parse(current);
|
|
371
|
+
const newCount = count + event.payload.amount;
|
|
330
372
|
// Read-modify-write on counter: use the version we just read so two
|
|
331
373
|
// concurrent increments don't clobber each other's changes.
|
|
332
374
|
return counterExecutor.update(
|
|
333
375
|
{
|
|
334
376
|
id: event.payload.id,
|
|
335
|
-
version
|
|
377
|
+
version,
|
|
336
378
|
changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
|
|
337
379
|
},
|
|
338
380
|
event.user,
|
|
@@ -358,17 +400,22 @@ export const counterFeature = defineFeature("counters", (r) => {
|
|
|
358
400
|
{ access: { roles: ["Admin"] } },
|
|
359
401
|
);
|
|
360
402
|
|
|
361
|
-
// Custom query: only counters above a threshold
|
|
403
|
+
// Custom query: only counters above a threshold. list() returns one page
|
|
404
|
+
// (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
|
|
405
|
+
// page 1; filter in the query instead. totalCount counts over the whole WHERE.
|
|
362
406
|
r.queryHandler(
|
|
363
407
|
"counter:active",
|
|
364
|
-
z.object({ minCount: z.number().default(1) }),
|
|
365
|
-
async (query, ctx) =>
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
408
|
+
z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
|
|
409
|
+
async (query, ctx) =>
|
|
410
|
+
counterExecutor.list(
|
|
411
|
+
{
|
|
412
|
+
filter: { field: "count", op: "gte", value: query.payload.minCount },
|
|
413
|
+
totalCount: true,
|
|
414
|
+
cursor: query.payload.cursor,
|
|
415
|
+
},
|
|
416
|
+
query.user,
|
|
417
|
+
ctx.db,
|
|
418
|
+
),
|
|
372
419
|
{
|
|
373
420
|
access: {
|
|
374
421
|
openToAll: {
|