From 7a8e9bf386f3735b9d5be73d08c58921f70839f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:18:21 +0000 Subject: [PATCH 1/2] fix(plugin-hono-server): a Hono door's 409 CONCURRENT_UPDATE carries currentRecord, the caller's own read of the row The protocol now re-reads a conflicting row under the caller's context before it throws ConcurrentUpdateError, so the record holds only what the caller may read. declaredArmContext stops holding currentRecord back, and both Hono doors forward it in error.details as RestServer forwards it. The absence pins flip to presence; the composed-host pin shows a field the caller's read masks staying off the record. The docs pages that said a Hono door never sends currentRecord are corrected. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../23531-hono-conflict-current-record.md | 17 +++ content/docs/api/index.mdx | 2 +- content/docs/api/wire-format.mdx | 17 ++- .../docs/protocol/kernel/error-handling.mdx | 5 +- .../plugins/plugin-hono-server/src/adapter.ts | 44 +++--- .../handler-throw-declared-envelope.test.ts | 27 ++-- ...hono-declared-envelope.conformance.test.ts | 133 +++++++++++++++--- 7 files changed, 178 insertions(+), 67 deletions(-) create mode 100644 .changeset/23531-hono-conflict-current-record.md diff --git a/.changeset/23531-hono-conflict-current-record.md b/.changeset/23531-hono-conflict-current-record.md new file mode 100644 index 00000000000..d24552f59a1 --- /dev/null +++ b/.changeset/23531-hono-conflict-current-record.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-hono-server': patch +'@objectstack/hono': patch +--- + +fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentRecord` as well, the caller's own read of the row, as the standalone REST server's does (#23531) + +The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried the server's `currentVersion` in `error.details`, and held `currentRecord` back while the protocol read that row with a system context. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it. + +- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…"}}}` +- TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` + +`currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. Status, `code`, `message` and `currentVersion` are unchanged, and no other answer changes. + +This supersedes the earlier note that `currentRecord` stays held on these two doors. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. + +Clause-②: no diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index bf9b704d7e1..1dfed5290f4 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -218,7 +218,7 @@ Validation failures additionally include a `fields` array (one entry per invalid } ``` -On a host built with `@objectstack/hono`'s `createHonoApp`, a declared refusal thrown out of the dispatcher (the `/data` routes' refusals among them, where its catch-all serves `/data`; a host that registers the kernel REST server's routes ahead of the catch-all, the hosted composition, answers those flat, as above) is answered in this envelope **without** `httpStatus`, and so is one escaping a route mounted on `HonoHttpServer`. The fields the kernel REST server's classification adds beside `code` ride in `details` there, for example `DELETE_RESTRICTED`'s `dependentObject`, except `currentRecord` and an `object` taken from the request's route — see [Wire Format](/docs/api/wire-format#hono-hosts-nest-the-extras). +On a host built with `@objectstack/hono`'s `createHonoApp`, a declared refusal thrown out of the dispatcher (the `/data` routes' refusals among them, where its catch-all serves `/data`; a host that registers the kernel REST server's routes ahead of the catch-all, the hosted composition, answers those flat, as above) is answered in this envelope **without** `httpStatus`, and so is one escaping a route mounted on `HonoHttpServer`. The fields the kernel REST server's classification adds beside `code` ride in `details` there, for example `DELETE_RESTRICTED`'s `dependentObject`, except an `object` taken from the request's route — see [Wire Format](/docs/api/wire-format#hono-hosts-nest-the-extras). Branch on `error.code`, never on `error.httpStatus` — the status answers "what class of failure" and the code answers "which failure". A branch that has no diff --git a/content/docs/api/wire-format.mdx b/content/docs/api/wire-format.mdx index a11fddd7029..6a186ed8770 100644 --- a/content/docs/api/wire-format.mdx +++ b/content/docs/api/wire-format.mdx @@ -487,9 +487,11 @@ release group); any code reading them directly should move to `error.code`. A host built with `@objectstack/hono`'s `createHonoApp`, and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`, answer a declared refusal that escapes the handler in the nested envelope above, without `httpStatus`. The extras the -REST door's classification adds (`fields`, `currentVersion`, `dependentObject`, …) ride in -`error.details`; `currentRecord`, and an `object` the flat body takes from the request's -route, are not sent. The concurrent update above, on a `createHonoApp` host whose catch-all serves `/data`: +REST door's classification adds (`fields`, `currentVersion`, `currentRecord`, `dependentObject`, …) +ride in `error.details`; an `object` the flat body takes from the request's route is not +sent. `currentRecord` is the row as the caller reads it, on either door: a field the caller's +field-level rules hide is absent from it. The concurrent update above, on a `createHonoApp` +host whose catch-all serves `/data`: ```json { @@ -497,7 +499,14 @@ route, are not sent. The concurrent update above, on a `createHonoApp` host whos "error": { "code": "CONCURRENT_UPDATE", "message": "Record task/tsk_01HQ4A7B9D3F5G8J2K4L was modified by another user (current version 2025-01-20T14:15:00.000Z, expected 2025-01-20T14:10:00.000Z)", - "details": { "currentVersion": "2025-01-20T14:15:00.000Z" } + "details": { + "currentVersion": "2025-01-20T14:15:00.000Z", + "currentRecord": { + "id": "tsk_01HQ4A7B9D3F5G8J2K4L", + "title": "Implement login page", + "updated_at": "2025-01-20T14:15:00.000Z" + } + } } } ``` diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 4e9e6e32909..fbe03d3cce7 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -247,14 +247,13 @@ serves `/data`: | Code | In `error.details` on a Hono door | |---|---| | `DELETE_RESTRICTED` | `developerMessage`, `dependentObject`, and `dependentCount` when the flat door would send it | -| `CONCURRENT_UPDATE` | `currentVersion` | +| `CONCURRENT_UPDATE` | `currentVersion`, `currentRecord` (the row as the caller reads it) | | `INVALID_FIELD` | `field`, `object` | | `OBJECT_NOT_FOUND` | `object` | | `VALIDATION_FAILED` | `fields` | | `FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE` | `object` | -⛔ What a Hono door never sends: `httpStatus`; `CONCURRENT_UPDATE`'s `currentRecord`, -which the flat door sends; and an `object` the flat door takes from the request's route +⛔ What a Hono door never sends: `httpStatus`, and an `object` the flat door takes from the request's route (on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the thrown one and can differ from the flat door's `error`: an unregistered object reads `Object 'ghost' not found` here and `Object 'ghost' is not registered` there. diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 7fd1f090b05..4b7ca0f6247 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -296,12 +296,13 @@ function toLoggableError(thrown: unknown): Error { * ## A structured refusal's context (#23531, #23587) * * A refusal `RestServer`'s classification answers with structured fields - * (`409 CONCURRENT_UPDATE`'s `currentVersion`, `409 DELETE_RESTRICTED`'s - * `developerMessage` / `dependentObject` / `dependentCount`, …) also carries - * them here, in `details`, read off that classification by - * {@link declaredArmContext}. Before this, both Hono doors answered those - * refusals with `code` and `message` only. `currentRecord` is held back for - * now (#23586); that helper's docblock says why. + * (`409 CONCURRENT_UPDATE`'s `currentVersion` and `currentRecord`, `409 + * DELETE_RESTRICTED`'s `developerMessage` / `dependentObject` / + * `dependentCount`, …) also carries them here, in `details`, read off that + * classification by {@link declaredArmContext}. Before this, both Hono doors + * answered those refusals with `code` and `message` only. `currentRecord` is + * forwarded because the producer reads it under the caller's own context + * (#23586, PR #23628); that helper's docblock says why that matters. */ export function declaredEnvelopeForThrow(thrown: unknown): { status: number; @@ -378,9 +379,8 @@ export function declaredEnvelopeForThrow(thrown: unknown): { * under the same rule (each field only when the producer stated it), and an * arm that grows a field grows it at every door. ⛔ Never list the fields or * the codes here. The arm's own sentence and code are dropped because this - * envelope already carries both, as `message` and `code`. `currentRecord` is - * the ONE field named here, and it is named only to hold it back (the ⚠️ - * paragraph below says why). + * envelope already carries both, as `message` and `code`. No field is held + * back by name (the ⚠️ paragraph below says what that rests on). * * ## The arm must agree with the envelope * @@ -412,18 +412,19 @@ export function declaredEnvelopeForThrow(thrown: unknown): { * `object` (`INVALID_FIELD`, `OBJECT_NOT_FOUND`, the 403 access arms) carry it * here as they do there. * - * ⚠️ `currentRecord` is HELD here: these doors do not forward it, though the - * REST door does. The protocol reads that row with a system context - * (`probeRecord`), and the OCC check throws it before the caller's write - * policy runs, so it is not narrowed to what the caller may read. It may carry - * a row, or fields, that row- or field-level security hides from the caller. - * Forwarding it would widen that possible disclosure to two more doors. The - * record waits for the producer's answer (#23586), and the exclusion is lifted - * when that lands. Every other field an arm carries today is a version token, + * ⚠️ Row data rides here only because its producer reads it as the caller. + * `CONCURRENT_UPDATE`'s `currentRecord` is a row: on a version mismatch the + * protocol re-reads it under the caller's own `context`, so every row- and + * field-level rule of the read door applies (#23586, PR #23628). A row the + * caller cannot read answers `404 RECORD_NOT_FOUND` with neither field, and a + * readable row's `currentRecord` is that field-masked read, which these doors + * forward as the REST door does. It was held here until that landed, while + * the record was the row the existence probe (`probeRecord`) reads with a + * system context. Every other field an arm carries today is a version token, * an API name, the caller's own input, or a count the producer has already * narrowed to what the caller may read (`dependentCountIsDisclosable` in - * `@objectstack/objectql`); none is row data read with a system context. An - * arm that grows such a field must be held here by name, as this one is. + * `@objectstack/objectql`). An arm that grows a field read with a system + * context must be held here by name until its producer reads it as the caller. */ function declaredArmContext( thrown: unknown, @@ -434,10 +435,7 @@ function declaredArmContext( return undefined; } const context = Object.fromEntries( - Object.entries(arm.body).filter(([key]) => key !== 'error' - && key !== 'code' - // Held: a system-context row the caller may not be allowed to read (#23586). - && key !== 'currentRecord'), + Object.entries(arm.body).filter(([key]) => key !== 'error' && key !== 'code'), ); return Object.keys(context).length > 0 ? context : undefined; } diff --git a/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts b/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts index e368ba93f3c..b2e45fc2305 100644 --- a/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts +++ b/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts @@ -216,27 +216,28 @@ describe('an escaped throw carrying a declared ADR-0112 envelope is rendered as /** * [#23531] A stale `If-Match` on a direct-mount route: the `409 - * CONCURRENT_UPDATE` carries the server's `currentVersion`, which `RestServer`'s - * `409` carries and objectui's conflict bar reads. + * CONCURRENT_UPDATE` carries the server's `currentVersion` and `currentRecord`, + * which `RestServer`'s `409` carries and objectui's conflict bar reads. * - * `currentRecord` is asserted ABSENT. `RestServer` forwards it, but the - * protocol reads that row with a system context before the caller's write - * policy runs, so it is held at these doors until the producer answers #23586. - * The producer below states it, so the absence is the door's doing. + * `currentRecord` is forwarded as the producer threw it, field for field: the + * row below stands for the caller's own read, which is what `metadata-protocol` + * puts there since #23586 (PR #23628). `error.details` is asserted EXACTLY, so + * a field the door drops or invents turns the row red. * * The producer below is shaped exactly like `metadata-protocol`'s * `ConcurrentUpdateError` (`name`, `code`, `status`, and the two own * properties), because this package does not depend on that one. The routes * judge the `If-Match` header against a stored version, as `updateData` / * `deleteData` do. The composed-host twin, over the REAL protocol, is - * `hono-declared-envelope.conformance.test.ts` in `packages/qa/http-conformance`. + * `hono-declared-envelope.conformance.test.ts` in `packages/qa/http-conformance`; + * it also shows a field the caller's read masks staying off `currentRecord`. * - * On the wrapped envelope the field rides `error.details`: that is the + * On the wrapped envelope the fields ride `error.details`: that is the * envelope's declared context slot (`ApiErrorSchema.details`), and it is where * `@objectstack/client` reads a wrapped body's context from * (`errorBody.error.details`). */ -describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries currentVersion, and holds currentRecord (#23586)', () => { +describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries currentVersion and currentRecord', () => { class ConcurrentUpdateError extends Error { readonly code = 'CONCURRENT_UPDATE'; readonly status = 409; @@ -274,7 +275,7 @@ describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries cur return s; } - it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion in `error.details`, and no currentRecord', async (method) => { + it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion and currentRecord in `error.details`, as the producer stated them', async (method) => { const res = await call(occServer(), '/api/v1/data/task/task_1', { method, headers: { 'If-Match': `"${STALE_VERSION}"`, ...(method === 'PATCH' ? { 'content-type': 'application/json' } : {}) }, @@ -285,10 +286,8 @@ describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries cur expect(res.status, JSON.stringify(body)).toBe(409); expect(body.success).toBe(false); expect(body.error.code).toBe('CONCURRENT_UPDATE'); - expect(body.error.details).toEqual({ currentVersion: STORED_VERSION }); - // Held until #23586: the producer stated it, and the door does not forward it. - expect(body.error.details).not.toHaveProperty('currentRecord'); - expect(JSON.stringify(body)).not.toContain('original'); + // Exactly the two fields the producer stated: nothing dropped, nothing added. + expect(body.error.details).toEqual({ currentVersion: STORED_VERSION, currentRecord: STORED }); }); it('control: the version the route stores is accepted, so the 409 rows are the guard and not the rig', async () => { diff --git a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts index 5cc14f9edc4..e425679ec2c 100644 --- a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts @@ -117,6 +117,21 @@ const CONTACT = { }, }; +/** + * [#23531] An object whose `secret` a caller's read masks (see the + * `[#23531]` block). The stored row keeps it; only a read issued with a + * caller's context loses it. + */ +const LEDGER = { + name: 'ledger', + label: 'Ledger', + fields: { + name: { name: 'name', type: 'text' as const, label: 'Name' }, + secret: { name: 'secret', type: 'text' as const, label: 'Secret' }, + }, +}; +const SECRET_TEXT = 'ledger-secret-the-caller-may-not-read'; + const liveKernels: LiteKernel[] = []; afterEach(async () => { vi.restoreAllMocks(); @@ -153,7 +168,7 @@ async function boot(auth?: Record) { await kernel.bootstrap(); const ql = kernel.getService('objectql'); - for (const object of [TASK, ACCOUNT, CONTACT]) { + for (const object of [TASK, ACCOUNT, CONTACT, LEDGER]) { ql.registerObject(object as any); await ql.syncObjectSchema(object.name); } @@ -265,34 +280,69 @@ describe('[#23365] a real createHonoApp host: the dispatch() catch-all renders a /** * [#23531] The stale-`If-Match` `409` above carries the server's - * `currentVersion`, as `RestServer`'s does. The wire contract and - * `ConcurrentUpdateError`'s docblock name it, and objectui's conflict bar reads - * it. Before this card the body was `code` and `message` only. + * `currentVersion` and `currentRecord`, as `RestServer`'s does. The wire + * contract and `ConcurrentUpdateError`'s docblock name both, and objectui's + * conflict bar reads both. Before this card the body was `code` and `message` + * only. * - * The field rides `error.details`, the wrapped envelope's declared context + * The fields ride `error.details`, the wrapped envelope's declared context * slot, which is where `@objectstack/client` reads a wrapped body's context * from. `currentVersion` is proved by USE, not by spelling: echoed back as the * next `If-Match`, it is accepted ("Overwrite anyway" in the conflict bar), so - * it is the record's real version token. + * it is the record's real version token. `error.details` is compared with the + * throw the real protocol raised, read off the protocol call, so the door is + * shown to relay the producer's two fields rather than to build its own. + * + * ## The record is the caller's read * - * `currentRecord` is asserted ABSENT. The real protocol below states it (the - * system-context row its `probeRecord` read, thrown before the caller's write - * policy runs), and `RestServer` forwards it. These doors hold it until the - * producer answers #23586, so the absence is the door's doing, not the - * producer's. + * On a version mismatch the protocol re-reads the row under the caller's own + * context (#23586, PR #23628), so a field the caller's read masks is absent + * from `currentRecord` on this door, while the store still holds it. The mask + * here is an engine middleware that rewrites a read issued with a caller's + * context after `next()`: the seam `@objectstack/plugin-security`'s + * field-level rules rewrite a read at. The security plugin itself is not + * booted, because this package does not depend on it. Its real field- and + * row-level rules are pinned at the producer, on `RestServer` and on the + * dispatcher, by `packages/runtime`'s `data-conflict-caller-visibility.test.ts`. */ -describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries currentVersion, and holds currentRecord (#23586)', () => { - it('PATCH with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => { +describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries currentVersion and currentRecord', () => { + type ProtocolWrite = 'updateData' | 'deleteData'; + const protocolWrite = (method: string): ProtocolWrite => (method === 'PATCH' ? 'updateData' : 'deleteData'); + + /** Each throw the real protocol raises from `write`, read off the call and rethrown. */ + const captureThrows = (protocol: any, write: ProtocolWrite): any[] => { + const produced: any[] = []; + const real = protocol[write].bind(protocol); + vi.spyOn(protocol, write).mockImplementation(async (...args: any[]) => { + try { + return await real(...args); + } catch (thrown) { + produced.push(thrown); + throw thrown; + } + }); + return produced; + }; + /** A thrown value as the wire carries it. */ + const asWire = (value: unknown) => JSON.parse(JSON.stringify(value)); + + it('PATCH with a stale If-Match: currentVersion and currentRecord in `error.details` as the protocol threw them, and the version accepted when echoed', async () => { const host = await boot(); const id = await host.seed('original'); + const produced = captureThrows(host.protocol, 'updateData'); const answer = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION }, body: { name: 'lost update' } }); expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); + // The real producer stated both; without this the equality below + // could hold between two absences. + expect(produced).toHaveLength(1); + expect(produced[0].currentRecord, JSON.stringify(produced[0])).toMatchObject({ id, name: 'original' }); const details = answer.body.error.details; - expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string'); - // Held until #23586: the protocol stated it, and the door does not forward it. - expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord'); - expect(JSON.stringify(answer.body)).not.toContain('original'); + expect(details, JSON.stringify(answer.body)).toEqual({ + currentVersion: produced[0].currentVersion, + currentRecord: asWire(produced[0].currentRecord), + }); + expect(typeof details.currentVersion).toBe('string'); expect(await host.stored(id)).toBe('original'); const overwrite = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` }, body: { name: 'overwritten' } }); @@ -300,23 +350,62 @@ describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries expect(await host.stored(id)).toBe('overwritten'); }, 60_000); - it('DELETE with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => { + it('DELETE with a stale If-Match: currentVersion and currentRecord in `error.details` as the protocol threw them, and the version accepted when echoed', async () => { const host = await boot(); const id = await host.seed('kept'); + const produced = captureThrows(host.protocol, 'deleteData'); const answer = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION } }); expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); + expect(produced).toHaveLength(1); + expect(produced[0].currentRecord, JSON.stringify(produced[0])).toMatchObject({ id, name: 'kept' }); const details = answer.body.error.details; - expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string'); - // Held until #23586: the protocol stated it, and the door does not forward it. - expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord'); - expect(JSON.stringify(answer.body)).not.toContain('kept'); + expect(details, JSON.stringify(answer.body)).toEqual({ + currentVersion: produced[0].currentVersion, + currentRecord: asWire(produced[0].currentRecord), + }); + expect(typeof details.currentVersion).toBe('string'); expect(await host.stored(id)).toBe('kept'); const overwrite = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` } }); expect(overwrite.status, JSON.stringify(overwrite.body)).toBe(200); expect(await host.stored(id)).toBeUndefined(); }, 60_000); + + it.each(['PATCH', 'DELETE'])('%s with a stale If-Match on a row whose field the caller\'s read masks: currentRecord is the caller\'s read, without that field', async (method) => { + const host = await boot(); + host.ql.registerMiddleware(async (op: any, next: () => Promise) => { + await next(); + if (op.object !== 'ledger' || (op.operation !== 'find' && op.operation !== 'findOne')) return; + if (!op.context || op.context.isSystem === true) return; + const rows = Array.isArray(op.result) ? op.result : op.result ? [op.result] : []; + for (const row of rows) delete row.secret; + }); + const created: any = await host.ql.insert('ledger', { name: 'visible', secret: SECRET_TEXT }); + const id = String(created.id); + const produced = captureThrows(host.protocol, protocolWrite(method)); + + const answer = await host.send(method, `/data/ledger/${id}`, { + headers: { 'If-Match': STALE_VERSION }, + ...(method === 'PATCH' ? { body: { name: 'lost update' } } : {}), + }); + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); + + const record = answer.body.error.details?.currentRecord; + expect(record, JSON.stringify(answer.body)).toMatchObject({ id, name: 'visible' }); + expect(record).not.toHaveProperty('secret'); + expect(JSON.stringify(answer.body)).not.toContain(SECRET_TEXT); + // It is the caller's own read of the row, and the record the protocol threw. + const callerRead = await host.ql.findOne('ledger', { where: { id }, context: { ...MEMBER } } as any); + expect(callerRead).not.toHaveProperty('secret'); + expect(record).toEqual(asWire(callerRead)); + expect(produced).toHaveLength(1); + expect(record).toEqual(asWire(produced[0].currentRecord)); + // The store still holds the field and the row, so the absence is the + // caller's mask, not the row's content. + const systemRead: any = await host.ql.findOne('ledger', { where: { id } } as any); + expect(systemRead).toMatchObject({ name: 'visible', secret: SECRET_TEXT }); + }, 60_000); }); /** From bc83077a4fdcc3609fad2fd68a476187effc4dc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:59:14 +0000 Subject: [PATCH 2/2] test(http-conformance): type the masked-read pin's findOne options instead of casting them check:query-options-erasure counts an `as any` on engine query options in test code; the two reads the #23531 masked-field row takes need no cast. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../src/hono-declared-envelope.conformance.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts index e425679ec2c..971d2181fca 100644 --- a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts @@ -396,14 +396,14 @@ describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries expect(record).not.toHaveProperty('secret'); expect(JSON.stringify(answer.body)).not.toContain(SECRET_TEXT); // It is the caller's own read of the row, and the record the protocol threw. - const callerRead = await host.ql.findOne('ledger', { where: { id }, context: { ...MEMBER } } as any); + const callerRead = await host.ql.findOne('ledger', { where: { id }, context: { ...MEMBER } }); expect(callerRead).not.toHaveProperty('secret'); expect(record).toEqual(asWire(callerRead)); expect(produced).toHaveLength(1); expect(record).toEqual(asWire(produced[0].currentRecord)); // The store still holds the field and the row, so the absence is the // caller's mask, not the row's content. - const systemRead: any = await host.ql.findOne('ledger', { where: { id } } as any); + const systemRead: any = await host.ql.findOne('ledger', { where: { id } }); expect(systemRead).toMatchObject({ name: 'visible', secret: SECRET_TEXT }); }, 60_000); });