@cosmicdrift/kumiko-framework 0.167.1 → 0.170.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/full-stack.integration.test.ts +16 -0
- package/src/changes.json +40 -0
- package/src/db/__tests__/eagerload.integration.test.ts +79 -3
- package/src/db/eagerload.ts +35 -1
- package/src/db/event-store-executor-write.ts +19 -7
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +97 -0
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +39 -0
- package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +83 -0
- package/src/engine/entity-handlers.ts +29 -9
- package/src/pipeline/dispatch-shared.ts +13 -1
- package/src/stack/request-helper.ts +48 -6
- package/src/testing/shared-entities.ts +14 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.170.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.170.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.170.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -493,6 +493,22 @@ describe("full stack: auth + access + validation", () => {
|
|
|
493
493
|
);
|
|
494
494
|
expectErrorIncludes(error, "banned_domain");
|
|
495
495
|
});
|
|
496
|
+
|
|
497
|
+
test("queryOk throws on a query error response instead of returning undefined", async () => {
|
|
498
|
+
await expect(
|
|
499
|
+
stack.http.queryOk("users:query:user:detail", { id: "not-a-uuid" }, adminUser),
|
|
500
|
+
).rejects.toThrow(/validation_error/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("queryErr asserts + returns the structured error for a failing query", async () => {
|
|
504
|
+
const error = await stack.http.queryErr(
|
|
505
|
+
"users:query:user:detail",
|
|
506
|
+
{ id: "not-a-uuid" },
|
|
507
|
+
adminUser,
|
|
508
|
+
);
|
|
509
|
+
expect(error.code).toBe("validation_error");
|
|
510
|
+
expect(error.httpStatus).toBe(400);
|
|
511
|
+
});
|
|
496
512
|
});
|
|
497
513
|
|
|
498
514
|
// =============================================================================
|
package/src/changes.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.167.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "resetEntityFieldEncryptionCacheForTests / resetEventPiiCatalogForTests moved to /testing (fw#1631).",
|
|
6
|
+
"detail": "Test-only reset helpers with no owning feature: resetEntityFieldEncryptionCacheForTests left the /db barrel, resetEventPiiCatalogForTests left /crypto. The functions did not move, only their export path.",
|
|
7
|
+
"migration": "Import both from \"@cosmicdrift/kumiko-framework/testing\" instead of \"/db\" and \"/crypto\". Relative deep-imports of the defining module are unaffected."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.167.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Six identity-sensitive error classes moved from kumiko-types into kumiko-framework (fw#1616).",
|
|
13
|
+
"detail": "VersionConflictError, IdempotentAppendConflictError and ArchivedStreamError now live in /event-store, KeyErasedError, KeyNotFoundError and KeyAlreadyExistsError in /crypto — the public paths callers already import from. With no classes left in it, kumiko-types is a plain dependency again instead of a peerDependency, which closes the changesets cycle that escalated every minor release to a major.",
|
|
14
|
+
"migration": "Only affects direct imports from the removed @cosmicdrift/kumiko-types/event-store-errors subpath: import from @cosmicdrift/kumiko-framework/event-store or /crypto instead. Apps importing from the framework paths need no change."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.167.0",
|
|
18
|
+
"type": "fix",
|
|
19
|
+
"title": "hono range raised to ^4.12.27 — security floor for the HTTP layer (fw#1634).",
|
|
20
|
+
"detail": "Carries the fixes for three advisories: cross-request data disclosure in hono/jsx (context not isolated per request), server-side XSS via a JSX escaping bypass in cx(), and a dropped repeated request header in the API-Gateway v1 adapter. The old ^4.12.18 allowed the patched versions, but the lockfile sat on 4.12.25 — the range now states the floor instead of relying on resolution luck."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.165.2",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "buildEntityTableMeta renamed to deriveEntityTableMeta (fw#1208).",
|
|
26
|
+
"detail": "The old name read like the unmanaged escape hatch (defineUnmanagedTable). Unmanaged builders now reject the reserved read_ table-name prefix. The deprecated alias still works."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.165.1",
|
|
30
|
+
"type": "fix",
|
|
31
|
+
"title": "isSafeHref decodes HTML character references before its scheme check (fw#1551).",
|
|
32
|
+
"detail": "javascript:alert(1) and java	script:alert(1) slipped through because neither contains a literal colon for the pre-decode regex, while the browser decodes the entity back into an executable javascript: URL on click. Affects renderSafeMarkdown (page-render) and the renderer-web Link primitive."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.165.1",
|
|
36
|
+
"type": "improvement",
|
|
37
|
+
"title": "createNumberField accepts max (fw#1573).",
|
|
38
|
+
"detail": "Mirrored from min; the schema-builder applies Zod .max() at the write boundary so integer CRUD rejects values that would overflow a Postgres integer instead of failing at insert time."
|
|
39
|
+
}
|
|
40
|
+
]
|
|
@@ -3,16 +3,28 @@
|
|
|
3
3
|
// werden (TenantDb filtert), sonst leakt eagerload fremde Rows nach _refs.
|
|
4
4
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
configurePiiSubjectKms,
|
|
8
|
+
encryptPiiFieldValues,
|
|
9
|
+
InMemoryKmsAdapter,
|
|
10
|
+
isPiiCiphertext,
|
|
11
|
+
} from "../../crypto";
|
|
7
12
|
import { createEntity, createTextField } from "../../engine";
|
|
8
13
|
import type { EntityDefinition } from "../../engine/types";
|
|
9
14
|
import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
|
|
15
|
+
import { createTestEnvelopeCipher, resetPiiSubjectKmsForTests, seedRows } from "../../testing";
|
|
10
16
|
import {
|
|
11
17
|
collectReferenceFields,
|
|
12
18
|
type EagerloadedRow,
|
|
13
19
|
enrichRowWithReferences,
|
|
14
20
|
enrichWithReferences,
|
|
15
21
|
} from "../eagerload";
|
|
22
|
+
import {
|
|
23
|
+
collectEncryptedFieldNames,
|
|
24
|
+
configureEntityFieldEncryption,
|
|
25
|
+
encryptEntityFieldValues,
|
|
26
|
+
resetEntityFieldEncryptionCacheForTests,
|
|
27
|
+
} from "../entity-field-encryption";
|
|
16
28
|
import { buildEntityTable } from "../table-builder";
|
|
17
29
|
import { createTenantDb } from "../tenant-db";
|
|
18
30
|
|
|
@@ -30,8 +42,31 @@ const postEntity = createEntity({
|
|
|
30
42
|
});
|
|
31
43
|
const authorTable = buildEntityTable("author", authorEntity);
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
// #1667: eager-loaded _refs bypassed PII/entity-field decryption entirely —
|
|
46
|
+
// a raw selectMany read the referenced row straight from the table, so
|
|
47
|
+
// piiCiphertextResponseGuard 500'd on the leaked ciphertext.
|
|
48
|
+
const contactEntity = createEntity({
|
|
49
|
+
table: "el_contacts",
|
|
50
|
+
fields: {
|
|
51
|
+
name: createTextField({ required: true }),
|
|
52
|
+
email: createTextField({ required: true, tenantOwned: true }),
|
|
53
|
+
iban: createTextField({ required: true, encrypted: true }),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const leadEntity = createEntity({
|
|
57
|
+
table: "el_leads",
|
|
58
|
+
fields: {
|
|
59
|
+
title: createTextField({ required: true }),
|
|
60
|
+
contact: { type: "reference", entity: "contact" },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const contactTable = buildEntityTable("contact", contactEntity);
|
|
64
|
+
|
|
65
|
+
const resolve = (name: string): EntityDefinition | undefined => {
|
|
66
|
+
if (name === "author") return authorEntity;
|
|
67
|
+
if (name === "contact") return contactEntity;
|
|
68
|
+
return undefined;
|
|
69
|
+
};
|
|
35
70
|
|
|
36
71
|
const tenantA = testTenantId(91);
|
|
37
72
|
const tenantB = testTenantId(92);
|
|
@@ -47,12 +82,18 @@ const single = (r: EagerloadedRow | undefined, f: string) =>
|
|
|
47
82
|
const many = (r: EagerloadedRow | undefined, f: string) =>
|
|
48
83
|
r?._refs?.[f] as ReadonlyArray<Record<string, unknown>> | undefined;
|
|
49
84
|
|
|
85
|
+
const CX = "44444444-4444-4444-8444-444444444444";
|
|
86
|
+
const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
|
|
87
|
+
const cipher = createTestEnvelopeCipher(TEST_KEY);
|
|
88
|
+
const kms = new InMemoryKmsAdapter();
|
|
89
|
+
|
|
50
90
|
let stack: TestStack;
|
|
51
91
|
let dbA: ReturnType<typeof createTenantDb>;
|
|
52
92
|
|
|
53
93
|
beforeAll(async () => {
|
|
54
94
|
stack = await setupTestStack({ features: [] });
|
|
55
95
|
await unsafeCreateEntityTable(stack.db, authorEntity);
|
|
96
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
56
97
|
dbA = createTenantDb(stack.db, tenantA, "tenant");
|
|
57
98
|
|
|
58
99
|
await seedRows(stack.db, authorTable, [
|
|
@@ -60,9 +101,30 @@ beforeAll(async () => {
|
|
|
60
101
|
{ id: A2, tenantId: tenantA, name: "Linus" },
|
|
61
102
|
{ id: BX, tenantId: tenantB, name: "Foreign" },
|
|
62
103
|
]);
|
|
104
|
+
|
|
105
|
+
configureEntityFieldEncryption(cipher);
|
|
106
|
+
configurePiiSubjectKms(kms);
|
|
107
|
+
const plainContact = {
|
|
108
|
+
id: CX,
|
|
109
|
+
tenantId: tenantA,
|
|
110
|
+
name: "Grace",
|
|
111
|
+
email: "grace@acme.test",
|
|
112
|
+
iban: "DE12345",
|
|
113
|
+
};
|
|
114
|
+
const piiEncrypted = await encryptPiiFieldValues(plainContact, contactEntity, ["email"], kms, {
|
|
115
|
+
requestId: "test",
|
|
116
|
+
});
|
|
117
|
+
const encrypted = await encryptEntityFieldValues(
|
|
118
|
+
piiEncrypted,
|
|
119
|
+
collectEncryptedFieldNames(contactEntity),
|
|
120
|
+
cipher,
|
|
121
|
+
);
|
|
122
|
+
await seedRows(stack.db, contactTable, [encrypted]);
|
|
63
123
|
});
|
|
64
124
|
|
|
65
125
|
afterAll(async () => {
|
|
126
|
+
resetEntityFieldEncryptionCacheForTests();
|
|
127
|
+
resetPiiSubjectKmsForTests();
|
|
66
128
|
await stack.cleanup();
|
|
67
129
|
});
|
|
68
130
|
|
|
@@ -161,4 +223,18 @@ describe("enrichWithReferences", () => {
|
|
|
161
223
|
)) as EagerloadedRow;
|
|
162
224
|
expect(single(row, "author")?.["name"]).toBe("Ada");
|
|
163
225
|
});
|
|
226
|
+
|
|
227
|
+
test("#1667: referenzierte PII-/encrypted-Felder werden entschlüsselt, keine Ciphertext-Leaks", async () => {
|
|
228
|
+
const [row] = (await enrichWithReferences(
|
|
229
|
+
[{ id: "l1", contact: CX }],
|
|
230
|
+
leadEntity,
|
|
231
|
+
resolve,
|
|
232
|
+
dbA,
|
|
233
|
+
)) as EagerloadedRow[];
|
|
234
|
+
const contact = single(row, "contact");
|
|
235
|
+
|
|
236
|
+
expect(contact?.["email"]).toBe("grace@acme.test");
|
|
237
|
+
expect(contact?.["iban"]).toBe("DE12345");
|
|
238
|
+
expect(isPiiCiphertext(contact?.["email"])).toBe(false);
|
|
239
|
+
});
|
|
164
240
|
});
|
package/src/db/eagerload.ts
CHANGED
|
@@ -23,8 +23,15 @@
|
|
|
23
23
|
// keine framework-engine-Internals und kann auch von custom
|
|
24
24
|
// query-handlern manuell aufgerufen werden.
|
|
25
25
|
|
|
26
|
+
import { requestContext } from "../api/request-context";
|
|
27
|
+
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto";
|
|
26
28
|
import { selectMany } from "../db/query";
|
|
27
29
|
import type { EntityDefinition, FieldDefinition, ReferenceFieldDef } from "../engine/types";
|
|
30
|
+
import {
|
|
31
|
+
collectEncryptedFieldNames,
|
|
32
|
+
decryptEntityFieldValues,
|
|
33
|
+
resolveEntityFieldEncryption,
|
|
34
|
+
} from "./entity-field-encryption";
|
|
28
35
|
import { buildEntityTable } from "./table-builder";
|
|
29
36
|
import type { TenantDb } from "./tenant-db";
|
|
30
37
|
|
|
@@ -81,6 +88,32 @@ export function collectReferenceFields(entity: EntityDefinition): readonly Refer
|
|
|
81
88
|
return out;
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
// Referenced rows are read via a raw selectMany, not the referenced entity's
|
|
92
|
+
// own executor context (enrichWithReferences only gets an
|
|
93
|
+
// EagerLoadEntityResolver — routing through buildExecutorContext per ref
|
|
94
|
+
// would need table/searchAdapter/entityCache wiring for no reason). Mirrors
|
|
95
|
+
// event-store-executor-context's decryptForRead ordering: PII is the outer
|
|
96
|
+
// layer, peel it before the envelope-encrypted fields, or the envelope
|
|
97
|
+
// cipher chokes on a still-PII-wrapped string.
|
|
98
|
+
async function decryptReferencedRow(
|
|
99
|
+
row: Record<string, unknown>,
|
|
100
|
+
refEntity: EntityDefinition,
|
|
101
|
+
): Promise<Record<string, unknown>> {
|
|
102
|
+
let out = row;
|
|
103
|
+
const piiFields = collectPiiSubjectFields(refEntity);
|
|
104
|
+
const kms = configuredPiiSubjectKms();
|
|
105
|
+
if (piiFields.length > 0 && kms) {
|
|
106
|
+
out = await decryptPiiFieldValues(out, piiFields, kms, {
|
|
107
|
+
requestId: requestContext.get()?.requestId ?? "eagerload",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
111
|
+
if (encryptedFields.size > 0) {
|
|
112
|
+
out = await decryptEntityFieldValues(out, encryptedFields, resolveEntityFieldEncryption());
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
84
117
|
/** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
|
|
85
118
|
* flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
|
|
86
119
|
export async function enrichWithReferences(
|
|
@@ -123,9 +156,10 @@ export async function enrichWithReferences(
|
|
|
123
156
|
}
|
|
124
157
|
const refTable = buildEntityTable(rf.refEntityName, refEntity);
|
|
125
158
|
const idArray = [...ids];
|
|
126
|
-
const
|
|
159
|
+
const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
|
|
127
160
|
Record<string, unknown>
|
|
128
161
|
>;
|
|
162
|
+
const refRows = await Promise.all(rawRefRows.map((r) => decryptReferencedRow(r, refEntity)));
|
|
129
163
|
const map = new Map<string, Record<string, unknown>>();
|
|
130
164
|
for (const r of refRows) {
|
|
131
165
|
const id = r["id"];
|
|
@@ -67,14 +67,19 @@ export function createWriteVerbs(
|
|
|
67
67
|
} = ctx;
|
|
68
68
|
|
|
69
69
|
return {
|
|
70
|
-
async create(payload, user, db) {
|
|
70
|
+
async create(payload, user, db, options) {
|
|
71
71
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
72
72
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
73
73
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
74
74
|
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
|
|
75
75
|
const aggregateId = explicitId ?? generateId();
|
|
76
76
|
const { id: _id, ...payloadWithoutId } = payload;
|
|
77
|
-
|
|
77
|
+
// preSave runs before ownership checks: authorization must evaluate the
|
|
78
|
+
// row as it will actually be persisted, including hook-derived fields
|
|
79
|
+
// (kumiko-framework#1672).
|
|
80
|
+
const data = options?.preSave
|
|
81
|
+
? await options.preSave(applyDefaults(payloadWithoutId), {}, true)
|
|
82
|
+
: applyDefaults(payloadWithoutId);
|
|
78
83
|
|
|
79
84
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
80
85
|
// only the new row is checked. No Straddle concern for creates.
|
|
@@ -221,12 +226,19 @@ export function createWriteVerbs(
|
|
|
221
226
|
const previous = await loadById(payload.id, db);
|
|
222
227
|
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
223
228
|
|
|
229
|
+
// preSave runs before ownership checks: authorization must evaluate the
|
|
230
|
+
// row as it will actually be persisted, including hook-derived fields
|
|
231
|
+
// (kumiko-framework#1672).
|
|
232
|
+
const changes = updateOptions?.preSave
|
|
233
|
+
? await updateOptions.preSave(payload.changes, previous, false)
|
|
234
|
+
: payload.changes;
|
|
235
|
+
|
|
224
236
|
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
225
237
|
// done above), build post-change row via shallow merge. Straddle-safe
|
|
226
238
|
// multi-role check: at least one role must accept BOTH old and new —
|
|
227
239
|
// prevents the attack where role A passes old, role B passes new and
|
|
228
240
|
// aggregation would wrongly allow a row-grab.
|
|
229
|
-
const mergedNew: Record<string, unknown> = { ...previous, ...
|
|
241
|
+
const mergedNew: Record<string, unknown> = { ...previous, ...changes };
|
|
230
242
|
if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
|
|
231
243
|
return writeFailure(
|
|
232
244
|
new UnprocessableError("ownership_denied", {
|
|
@@ -247,7 +259,7 @@ export function createWriteVerbs(
|
|
|
247
259
|
// `previous`, we can run the ownership rules per field against both
|
|
248
260
|
// sides and reject individual fields the user isn't entitled to
|
|
249
261
|
// touch on this specific row.
|
|
250
|
-
const fieldDeniedUpdate = checkWriteFieldOwnership(entity,
|
|
262
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, changes, user, previous);
|
|
251
263
|
if (fieldDeniedUpdate) {
|
|
252
264
|
return writeFailure(
|
|
253
265
|
new UnprocessableError("ownership_denied", {
|
|
@@ -303,11 +315,11 @@ export function createWriteVerbs(
|
|
|
303
315
|
// ownerField — the merged row still names the subject.
|
|
304
316
|
const submittedChanges = updateOptions?.skipUnchanged
|
|
305
317
|
? Object.fromEntries(
|
|
306
|
-
Object.entries(
|
|
318
|
+
Object.entries(changes).filter(
|
|
307
319
|
([key, value]) => !isUnchangedValue(value, previous[key]),
|
|
308
320
|
),
|
|
309
321
|
)
|
|
310
|
-
:
|
|
322
|
+
: changes;
|
|
311
323
|
const flatChangesPlain = flattenCompoundTypes(submittedChanges, entity);
|
|
312
324
|
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
313
325
|
onlyKeys: Object.keys(submittedChanges),
|
|
@@ -368,7 +380,7 @@ export function createWriteVerbs(
|
|
|
368
380
|
kind: "save",
|
|
369
381
|
id: data["id"] as EntityId, // @cast-boundary engine-payload
|
|
370
382
|
data,
|
|
371
|
-
changes
|
|
383
|
+
changes,
|
|
372
384
|
previous,
|
|
373
385
|
isNew: false,
|
|
374
386
|
entityName,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Regression coverage for kumiko-framework#1672 — preSave hooks were
|
|
2
|
+
// registered and boot-validated but never invoked by the dispatch path,
|
|
3
|
+
// making `r.hook("preSave", ...)` a silent no-op. This exercises the real
|
|
4
|
+
// HTTP dispatcher (not a hand-fed handler context) so the fix is proven at
|
|
5
|
+
// the layer app authors actually depend on.
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { asRawClient } from "../../db/query";
|
|
8
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
9
|
+
import { defineFeature } from "../define-feature";
|
|
10
|
+
import { createEntity, createTextField } from "../factories";
|
|
11
|
+
|
|
12
|
+
const contactEntity = createEntity({
|
|
13
|
+
table: "presave_wiring_contacts",
|
|
14
|
+
fields: {
|
|
15
|
+
firstName: createTextField({ required: true }),
|
|
16
|
+
lastName: createTextField({ required: true }),
|
|
17
|
+
displayName: createTextField(),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const seenIsNew: boolean[] = [];
|
|
22
|
+
|
|
23
|
+
const deriveDisplayName: import("../types").PreSaveHookFn = async (changes, ctx) => {
|
|
24
|
+
seenIsNew.push(ctx.isNew);
|
|
25
|
+
const first =
|
|
26
|
+
(changes["firstName"] as string | undefined) ??
|
|
27
|
+
(ctx.previous["firstName"] as string | undefined);
|
|
28
|
+
const last =
|
|
29
|
+
(changes["lastName"] as string | undefined) ?? (ctx.previous["lastName"] as string | undefined);
|
|
30
|
+
return { ...changes, displayName: `${first ?? ""} ${last ?? ""}`.trim() };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const contactFeature = defineFeature("presave-wiring", (r) => {
|
|
34
|
+
r.crud("contact", contactEntity, {
|
|
35
|
+
write: { access: { roles: ["User"] } },
|
|
36
|
+
read: { access: { openToAll: true } },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// preSave has no entity-wide `{ allOf }` shorthand (unlike postSave/
|
|
40
|
+
// preDelete/postDelete) — r.crud registers separate create/update
|
|
41
|
+
// handlers, so both need their own target.
|
|
42
|
+
r.hook("preSave", "contact:create", deriveDisplayName);
|
|
43
|
+
r.hook("preSave", "contact:update", deriveDisplayName);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const CREATE = "presave-wiring:write:contact:create";
|
|
47
|
+
const UPDATE = "presave-wiring:write:contact:update";
|
|
48
|
+
|
|
49
|
+
describe("preSave hooks — real dispatcher path (#1672)", () => {
|
|
50
|
+
let stack: TestStack;
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
stack = await setupTestStack({ features: [contactFeature] });
|
|
54
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await stack.cleanup();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
seenIsNew.length = 0;
|
|
63
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
64
|
+
await asRawClient(stack.db).unsafe('DELETE FROM "presave_wiring_contacts"');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("create: preSave hook derives displayName before persistence", async () => {
|
|
68
|
+
const res = await stack.http.write(
|
|
69
|
+
CREATE,
|
|
70
|
+
{ firstName: "Marc", lastName: "Ristone" },
|
|
71
|
+
TestUsers.user,
|
|
72
|
+
);
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
const { data } = (await res.json()) as { data: { data: { displayName: string } } };
|
|
75
|
+
expect(data.data.displayName).toBe("Marc Ristone");
|
|
76
|
+
expect(seenIsNew).toEqual([true]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("update: preSave hook sees previous row and re-derives displayName", async () => {
|
|
80
|
+
const created = await stack.http.write(
|
|
81
|
+
CREATE,
|
|
82
|
+
{ firstName: "Marc", lastName: "Ristone" },
|
|
83
|
+
TestUsers.user,
|
|
84
|
+
);
|
|
85
|
+
const { data } = (await created.json()) as { data: { data: { id: string; version: number } } };
|
|
86
|
+
|
|
87
|
+
const res = await stack.http.write(
|
|
88
|
+
UPDATE,
|
|
89
|
+
{ id: data.data.id, version: data.data.version, changes: { lastName: "Kumiko" } },
|
|
90
|
+
TestUsers.user,
|
|
91
|
+
);
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
const { data: updated } = (await res.json()) as { data: { data: { displayName: string } } };
|
|
94
|
+
expect(updated.data.displayName).toBe("Marc Kumiko");
|
|
95
|
+
expect(seenIsNew).toEqual([true, false]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -25,4 +25,43 @@ describe("r.crud", () => {
|
|
|
25
25
|
["task:detail", "task:list"].sort(),
|
|
26
26
|
);
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
test("without verbAccess, write handlers keep write.access (never fall back to the broader read.access)", () => {
|
|
30
|
+
const write = { access: { roles: ["Manager"] } } as const;
|
|
31
|
+
const read = { access: { openToAll: true } } as const;
|
|
32
|
+
|
|
33
|
+
const feature = defineFeature("via-crud-no-verb-access", (r) => {
|
|
34
|
+
r.crud("task", taskEntity, { write, read });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
for (const verb of ["create", "update", "delete", "restore"] as const) {
|
|
38
|
+
expect(feature.writeHandlers?.[`task:${verb}`]?.access).toEqual(write.access);
|
|
39
|
+
}
|
|
40
|
+
for (const verb of ["list", "detail"] as const) {
|
|
41
|
+
expect(feature.queryHandlers?.[`task:${verb}`]?.access).toEqual(read.access);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("verbAccess overrides access per verb, other verbs keep write/read.access", () => {
|
|
46
|
+
const write = { access: { roles: ["Manager"] } } as const;
|
|
47
|
+
const read = { access: { openToAll: true } } as const;
|
|
48
|
+
const deleteAccess = { roles: ["Operator"] } as const;
|
|
49
|
+
const restoreAccess = { roles: ["Operator"] } as const;
|
|
50
|
+
const listAccess = { roles: ["Auditor"] } as const;
|
|
51
|
+
|
|
52
|
+
const feature = defineFeature("via-crud-verb-access", (r) => {
|
|
53
|
+
r.crud("task", taskEntity, {
|
|
54
|
+
write,
|
|
55
|
+
read,
|
|
56
|
+
verbAccess: { delete: deleteAccess, restore: restoreAccess, list: listAccess },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(feature.writeHandlers?.["task:create"]?.access).toEqual(write.access);
|
|
61
|
+
expect(feature.writeHandlers?.["task:update"]?.access).toEqual(write.access);
|
|
62
|
+
expect(feature.writeHandlers?.["task:delete"]?.access).toEqual(deleteAccess);
|
|
63
|
+
expect(feature.writeHandlers?.["task:restore"]?.access).toEqual(restoreAccess);
|
|
64
|
+
expect(feature.queryHandlers?.["task:list"]?.access).toEqual(listAccess);
|
|
65
|
+
expect(feature.queryHandlers?.["task:detail"]?.access).toEqual(read.access);
|
|
66
|
+
});
|
|
28
67
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { asRawClient } from "../../db/query";
|
|
3
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
4
|
+
import { defineFeature } from "../define-feature";
|
|
5
|
+
import { createEntity, createTextField } from "../factories";
|
|
6
|
+
|
|
7
|
+
const propertyEntity = createEntity({
|
|
8
|
+
table: "crud_verb_access_properties",
|
|
9
|
+
fields: { title: createTextField({ required: true }) },
|
|
10
|
+
softDelete: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const propertyFeature = defineFeature("crud-verb-access", (r) => {
|
|
14
|
+
r.crud("property", propertyEntity, {
|
|
15
|
+
write: { access: { roles: ["User"] } },
|
|
16
|
+
read: { access: { openToAll: true } },
|
|
17
|
+
verbAccess: {
|
|
18
|
+
delete: { roles: ["Admin"] },
|
|
19
|
+
restore: { roles: ["Admin"] },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const CREATE = "crud-verb-access:write:property:create";
|
|
25
|
+
const DELETE = "crud-verb-access:write:property:delete";
|
|
26
|
+
const RESTORE = "crud-verb-access:write:property:restore";
|
|
27
|
+
|
|
28
|
+
describe("r.crud verbAccess — real dispatcher path", () => {
|
|
29
|
+
let stack: TestStack;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
stack = await setupTestStack({ features: [propertyFeature] });
|
|
33
|
+
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await stack.cleanup();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
42
|
+
await asRawClient(stack.db).unsafe('DELETE FROM "crud_verb_access_properties"');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("create stays gated by write.access — User role passes", async () => {
|
|
46
|
+
const res = await stack.http.write(CREATE, { title: "verbAccess create" }, TestUsers.user);
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("delete overridden by verbAccess — User role (write.access) is rejected", async () => {
|
|
51
|
+
const created = await stack.http.write(CREATE, { title: "to be deleted" }, TestUsers.user);
|
|
52
|
+
const { data } = (await created.json()) as { data: { id: string } };
|
|
53
|
+
|
|
54
|
+
const res = await stack.http.write(DELETE, { id: data.id }, TestUsers.user);
|
|
55
|
+
expect(res.status).toBe(403);
|
|
56
|
+
const body = (await res.json()) as { isSuccess: false; error: { code: string } };
|
|
57
|
+
expect(body.error.code).toBe("access_denied");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("delete overridden by verbAccess — Admin role (verbAccess.delete) passes", async () => {
|
|
61
|
+
const created = await stack.http.write(
|
|
62
|
+
CREATE,
|
|
63
|
+
{ title: "to be deleted by admin" },
|
|
64
|
+
TestUsers.user,
|
|
65
|
+
);
|
|
66
|
+
const { data } = (await created.json()) as { data: { id: string } };
|
|
67
|
+
|
|
68
|
+
const res = await stack.http.write(DELETE, { id: data.id }, TestUsers.admin);
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("restore overridden by verbAccess — Admin role passes, User role rejected", async () => {
|
|
73
|
+
const created = await stack.http.write(CREATE, { title: "to be restored" }, TestUsers.user);
|
|
74
|
+
const { data } = (await created.json()) as { data: { id: string } };
|
|
75
|
+
await stack.http.write(DELETE, { id: data.id }, TestUsers.admin);
|
|
76
|
+
|
|
77
|
+
const deniedRestore = await stack.http.write(RESTORE, { id: data.id }, TestUsers.user);
|
|
78
|
+
expect(deniedRestore.status).toBe(403);
|
|
79
|
+
|
|
80
|
+
const allowedRestore = await stack.http.write(RESTORE, { id: data.id }, TestUsers.admin);
|
|
81
|
+
expect(allowedRestore.status).toBe(200);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -182,7 +182,14 @@ export function defineEntityWriteHandler(
|
|
|
182
182
|
switch (verb) {
|
|
183
183
|
case "create":
|
|
184
184
|
schema = buildInsertSchema(entity);
|
|
185
|
-
handler = async (event, ctx) =>
|
|
185
|
+
handler = async (event, ctx) => {
|
|
186
|
+
const { runPreSave } = ctx;
|
|
187
|
+
return executor.create(event.payload as DbRow, event.user, ctx.db, {
|
|
188
|
+
preSave:
|
|
189
|
+
runPreSave &&
|
|
190
|
+
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
191
|
+
});
|
|
192
|
+
};
|
|
186
193
|
break;
|
|
187
194
|
case "update":
|
|
188
195
|
schema = z.object({
|
|
@@ -190,16 +197,21 @@ export function defineEntityWriteHandler(
|
|
|
190
197
|
version: z.number(),
|
|
191
198
|
changes: buildUpdateSchema(entity),
|
|
192
199
|
});
|
|
193
|
-
handler = async (event, ctx) =>
|
|
200
|
+
handler = async (event, ctx) => {
|
|
201
|
+
const { runPreSave } = ctx;
|
|
194
202
|
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
195
203
|
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
196
204
|
// pii/encrypted ciphertext. Direct executor.update() callers (e.g.
|
|
197
205
|
// KEK-rotation, the user-data-rights #494 backfill) don't go through
|
|
198
206
|
// this handler and keep today's always-re-encrypt behavior, which
|
|
199
207
|
// they rely on to intentionally force a fresh event/ciphertext.
|
|
200
|
-
executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
|
|
208
|
+
return executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
|
|
201
209
|
skipUnchanged: true,
|
|
210
|
+
preSave:
|
|
211
|
+
runPreSave &&
|
|
212
|
+
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
202
213
|
}); // @cast-boundary engine-payload
|
|
214
|
+
};
|
|
203
215
|
break;
|
|
204
216
|
case "delete":
|
|
205
217
|
schema = idSchema;
|
|
@@ -479,23 +491,31 @@ export function registerEntityCrud(
|
|
|
479
491
|
}
|
|
480
492
|
const writeOpts = options?.write;
|
|
481
493
|
const readOpts = options?.read;
|
|
494
|
+
const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
|
|
495
|
+
...writeOpts,
|
|
496
|
+
access: options?.verbAccess?.[verb] ?? writeOpts?.access,
|
|
497
|
+
});
|
|
498
|
+
const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
|
|
499
|
+
...readOpts,
|
|
500
|
+
access: options?.verbAccess?.[verb] ?? readOpts?.access,
|
|
501
|
+
});
|
|
482
502
|
|
|
483
503
|
if (verbs.create) {
|
|
484
|
-
r.writeHandler(defineEntityCreateHandler(entityName, entity,
|
|
504
|
+
r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
|
|
485
505
|
}
|
|
486
506
|
if (verbs.update) {
|
|
487
|
-
r.writeHandler(defineEntityUpdateHandler(entityName, entity,
|
|
507
|
+
r.writeHandler(defineEntityUpdateHandler(entityName, entity, resolveWriteOpts("update")));
|
|
488
508
|
}
|
|
489
509
|
if (verbs.delete) {
|
|
490
|
-
r.writeHandler(defineEntityDeleteHandler(entityName, entity,
|
|
510
|
+
r.writeHandler(defineEntityDeleteHandler(entityName, entity, resolveWriteOpts("delete")));
|
|
491
511
|
}
|
|
492
512
|
if (verbs.restore) {
|
|
493
|
-
r.writeHandler(defineEntityRestoreHandler(entityName, entity,
|
|
513
|
+
r.writeHandler(defineEntityRestoreHandler(entityName, entity, resolveWriteOpts("restore")));
|
|
494
514
|
}
|
|
495
515
|
if (verbs.list) {
|
|
496
|
-
r.queryHandler(defineEntityListHandler(entityName, entity,
|
|
516
|
+
r.queryHandler(defineEntityListHandler(entityName, entity, resolveReadOpts("list")));
|
|
497
517
|
}
|
|
498
518
|
if (verbs.detail) {
|
|
499
|
-
r.queryHandler(defineEntityDetailHandler(entityName, entity,
|
|
519
|
+
r.queryHandler(defineEntityDetailHandler(entityName, entity, resolveReadOpts("detail")));
|
|
500
520
|
}
|
|
501
521
|
}
|
|
@@ -154,7 +154,7 @@ export async function buildHandlerContext(
|
|
|
154
154
|
afterCommitHooks?: AfterCommitHook[],
|
|
155
155
|
includeDeleted?: boolean,
|
|
156
156
|
): Promise<HandlerContext> {
|
|
157
|
-
const { registry, appContext: context, effectiveFeatures, jobRunner } = ctx;
|
|
157
|
+
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
158
158
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
159
159
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
160
160
|
// AppContext's `db` union also allows TenantDb (for downstream hook calls),
|
|
@@ -537,6 +537,18 @@ export async function buildHandlerContext(
|
|
|
537
537
|
notify,
|
|
538
538
|
...(config && { config }),
|
|
539
539
|
...(files && { files }),
|
|
540
|
+
// preSave hooks need `changes`/`previous`/`isNew`, which only exist once
|
|
541
|
+
// a handler actually starts building its write — bound here so entity
|
|
542
|
+
// CRUD handlers (entity-handlers.ts) can forward it to the executor
|
|
543
|
+
// (kumiko-framework#1672).
|
|
544
|
+
...(lifecycle && {
|
|
545
|
+
runPreSave: (
|
|
546
|
+
handlerName: string,
|
|
547
|
+
changes: Record<string, unknown>,
|
|
548
|
+
previous: Readonly<Record<string, unknown>>,
|
|
549
|
+
isNew: boolean,
|
|
550
|
+
) => lifecycle.runPreSave(handlerName, changes, previous, isNew, context),
|
|
551
|
+
}),
|
|
540
552
|
tracer,
|
|
541
553
|
metrics,
|
|
542
554
|
tz,
|
|
@@ -14,7 +14,7 @@ type WireErrorBody = {
|
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
function
|
|
17
|
+
function formatFailure(kind: "write" | "query", type: string, body: unknown): string {
|
|
18
18
|
const parsed = body as {
|
|
19
19
|
isSuccess?: boolean;
|
|
20
20
|
error?: WireErrorBody | string;
|
|
@@ -35,12 +35,12 @@ function formatWriteFailure(type: string, body: unknown): string {
|
|
|
35
35
|
? String((details as { causeName?: unknown }).causeName ?? "")
|
|
36
36
|
: "";
|
|
37
37
|
if (code === "internal_error" && (causeMessage || causeName)) {
|
|
38
|
-
return `Expected
|
|
38
|
+
return `Expected ${kind} "${type}" to succeed but got error: ${code} (${causeName}: ${causeMessage})`;
|
|
39
39
|
}
|
|
40
40
|
if (details !== undefined) {
|
|
41
|
-
return `Expected
|
|
41
|
+
return `Expected ${kind} "${type}" to succeed but got error: ${code} — ${JSON.stringify(details)}`;
|
|
42
42
|
}
|
|
43
|
-
return `Expected
|
|
43
|
+
return `Expected ${kind} "${type}" to succeed but got error: ${code}`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export type RequestHelper = {
|
|
@@ -80,6 +80,13 @@ export type RequestHelper = {
|
|
|
80
80
|
) => Promise<import("../errors").WriteErrorInfo>;
|
|
81
81
|
/** query + json — returns data directly */
|
|
82
82
|
queryOk: <T = unknown>(type: string, payload: unknown, user: SessionUser) => Promise<T>;
|
|
83
|
+
/** query + json + assert the response is an error — returns the structured
|
|
84
|
+
* WriteErrorInfo with `httpStatus` filled in from the HTTP response. */
|
|
85
|
+
queryErr: (
|
|
86
|
+
type: string,
|
|
87
|
+
payload: unknown,
|
|
88
|
+
user: SessionUser,
|
|
89
|
+
) => Promise<import("../errors").WriteErrorInfo>;
|
|
83
90
|
|
|
84
91
|
/** write + additional HTTP headers (e.g. X-Correlation-ID). Returns the
|
|
85
92
|
* raw Response so callers can assert on status + headers + body as needed. */
|
|
@@ -205,7 +212,7 @@ export function createRequestHelper(
|
|
|
205
212
|
// follow the error-contract shape { error: { code, i18nKey, ... } } with
|
|
206
213
|
// a 4xx/5xx status — no isSuccess flag. Detect either.
|
|
207
214
|
if (body.isSuccess !== true) {
|
|
208
|
-
throw new Error(
|
|
215
|
+
throw new Error(formatFailure("write", type, body));
|
|
209
216
|
}
|
|
210
217
|
return body.data as T; // @cast-boundary engine-bridge
|
|
211
218
|
},
|
|
@@ -239,10 +246,45 @@ export function createRequestHelper(
|
|
|
239
246
|
|
|
240
247
|
async queryOk<T = unknown>(type: string, payload: unknown, user: SessionUser): Promise<T> {
|
|
241
248
|
const res = await queryRaw(type, payload, user);
|
|
242
|
-
const
|
|
249
|
+
const rawBody = await res.json();
|
|
250
|
+
const body = rawBody as {
|
|
251
|
+
// @cast-boundary engine-bridge
|
|
252
|
+
data?: unknown;
|
|
253
|
+
error?: WireErrorBody | string;
|
|
254
|
+
};
|
|
255
|
+
// res.ok mirrors writeOk's isSuccess assertion — belt-and-suspenders
|
|
256
|
+
// in case a future non-dispatcher rejection skips the error-contract shape.
|
|
257
|
+
if (!res.ok || body.error !== undefined) {
|
|
258
|
+
throw new Error(`${formatFailure("query", type, body)} [HTTP ${res.status}]`);
|
|
259
|
+
}
|
|
243
260
|
return body.data as T; // @cast-boundary engine-bridge
|
|
244
261
|
},
|
|
245
262
|
|
|
263
|
+
async queryErr(
|
|
264
|
+
type: string,
|
|
265
|
+
payload: unknown,
|
|
266
|
+
user: SessionUser,
|
|
267
|
+
): Promise<import("../errors").WriteErrorInfo> {
|
|
268
|
+
const res = await queryRaw(type, payload, user);
|
|
269
|
+
const rawErrorBody = await res.json();
|
|
270
|
+
const body = rawErrorBody as {
|
|
271
|
+
// @cast-boundary engine-bridge
|
|
272
|
+
error?: Omit<import("../errors").WriteErrorInfo, "httpStatus">;
|
|
273
|
+
};
|
|
274
|
+
if (res.ok) {
|
|
275
|
+
throw new Error(`Expected query "${type}" to fail but it succeeded`);
|
|
276
|
+
}
|
|
277
|
+
const wire = body.error;
|
|
278
|
+
if (!wire || typeof wire !== "object" || typeof wire.code !== "string") {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Expected error response for "${type}" but got unexpected shape: ${JSON.stringify(body)}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
// Same rationale as writeErr: the wire body has no httpStatus (it would
|
|
284
|
+
// be redundant with the HTTP response status), so fill it in here.
|
|
285
|
+
return { ...wire, httpStatus: res.status };
|
|
286
|
+
},
|
|
287
|
+
|
|
246
288
|
async writeWithHeaders(type, payload, user, extraHeaders) {
|
|
247
289
|
const authHeaders = await authHeader(user);
|
|
248
290
|
return req("POST", "/api/write", { type, payload }, { ...authHeaders, ...extraHeaders });
|
|
@@ -27,9 +27,20 @@ export const sharedWidgetTable = buildEntityTable("widget", sharedWidgetEntity);
|
|
|
27
27
|
// realistic-looking user record.
|
|
28
28
|
export const sharedUserEntity = createEntity({
|
|
29
29
|
fields: {
|
|
30
|
-
email: createTextField({
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
email: createTextField({
|
|
31
|
+
required: true,
|
|
32
|
+
format: "email",
|
|
33
|
+
searchable: true,
|
|
34
|
+
allowPlaintext: "shared test fixture, not real user data",
|
|
35
|
+
}),
|
|
36
|
+
firstName: createTextField({
|
|
37
|
+
searchable: true,
|
|
38
|
+
allowPlaintext: "shared test fixture, not real user data",
|
|
39
|
+
}),
|
|
40
|
+
lastName: createTextField({
|
|
41
|
+
searchable: true,
|
|
42
|
+
allowPlaintext: "shared test fixture, not real user data",
|
|
43
|
+
}),
|
|
33
44
|
isEnabled: createBooleanField({ default: true }),
|
|
34
45
|
},
|
|
35
46
|
softDelete: true,
|