|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#7823 / #8497] The write-response half of the `internal: true` guarantee — |
| 5 | + * THE single helper every write mouth that returns a body to an external |
| 6 | + * caller passes its record(s) through. |
| 7 | + * |
| 8 | + * ## The contract |
| 9 | + * |
| 10 | + * A field declared `internal: true` is *never returned on the generic data |
| 11 | + * path* (#7728). The READ half lives in the engine (`omitInternalFields` runs |
| 12 | + * on every find/findOne result). The WRITE-RESPONSE half lives HERE. |
| 13 | + * |
| 14 | + * ## Why the ingress and not the engine (the measured history, #7823) |
| 15 | + * |
| 16 | + * The first shape stripped `internal` fields inside the engine's insert and |
| 17 | + * by-id-update paths. That conflated two different guarantees: |
| 18 | + * |
| 19 | + * - "never returned on the generic data path" — the flag's sentence, about |
| 20 | + * what an EXTERNAL caller receives; and |
| 21 | + * - "never returned to the engine-level caller that performed the write" — |
| 22 | + * which no ruling ever asked for, and which is FALSE for credential mint: |
| 23 | + * better-auth's `createWithHooks` reads the minted `sys_session` row back |
| 24 | + * off the insert result, so the engine-side strip broke `signIn`/`signUp` |
| 25 | + * outright (measured: `verify signIn: no token in response`). |
| 26 | + * |
| 27 | + * Plain removal of the engine limbs was ALSO measured wrong: the by-id-update |
| 28 | + * strip was the sole closure of #7728's fourth surface — with it neutralised, |
| 29 | + * `PATCH /data/sys_api_key/{id}` answered 200 with the stored 64-hex `key` |
| 30 | + * hash in the body. Both measurements are satisfiable at exactly one boundary: |
| 31 | + * the mouth that builds the external 201/200 body. Engine write results keep |
| 32 | + * the stored row whole (mint works); every external write response is stripped |
| 33 | + * through this helper (the hash never leaves); the read path is untouched. |
| 34 | + * |
| 35 | + * ## Why this module sits in `@objectstack/core` (#8497) |
| 36 | + * |
| 37 | + * It shipped inside `@objectstack/metadata-protocol`, next to the protocol |
| 38 | + * class that was then its only caller. That placement encoded an assumption |
| 39 | + * the surface does not honour: **the generic write mouths are not all on the |
| 40 | + * protocol class.** Two transports reach the engine directly — |
| 41 | + * |
| 42 | + * - `@objectstack/rest` (`rest-server.ts`, the cross-object `POST /batch` |
| 43 | + * update arm's direct `ql.update`), and |
| 44 | + * - `@objectstack/mcp` (`stdio-data-bridge.ts`, whose `create` handed the |
| 45 | + * engine's insert result straight back to the MCP client — a MEASURED leak |
| 46 | + * of the flagged column, found by widening this guard's scope in #8497), |
| 47 | + * |
| 48 | + * — and neither package depends on `@objectstack/metadata-protocol`. The old |
| 49 | + * home therefore forced each new mouth to choose between a duck-typed reach |
| 50 | + * through a protocol instance (what `rest` does) and a private restatement of |
| 51 | + * the rule (a third copy of a security-relevant predicate). `@objectstack/core` |
| 52 | + * is the floor all three already depend on, and it already hosts exactly this |
| 53 | + * class of shared write-path helper (`bulk-write.ts`, used by both |
| 54 | + * `metadata-protocol` and `rest` so neither reimplements batching). One helper, |
| 55 | + * reachable from every mouth, is the whole point of the flag being structural. |
| 56 | + * |
| 57 | + * `@objectstack/metadata-protocol` re-exports both functions unchanged, so its |
| 58 | + * public API is byte-identical across the move. |
| 59 | + * |
| 60 | + * ## The residual risk, and what gates it |
| 61 | + * |
| 62 | + * Response-body policy at the mouth means a FUTURE write mouth that forgets |
| 63 | + * this helper leaks silently. Three tripwires hold the property, each an |
| 64 | + * enumeration no author can dodge by adding code without touching it: |
| 65 | + * |
| 66 | + * - `protocol.write-response-internal-fields.tripwire.test.ts` |
| 67 | + * (`metadata-protocol`) walks the protocol class's prototype for `*Data` |
| 68 | + * faces; |
| 69 | + * - `rest-write-response-internal-fields.tripwire.test.ts` (`rest`) walks |
| 70 | + * `RestServer.getRoutes()` for HTTP write routes; |
| 71 | + * - `mcp-write-response-internal-fields.tripwire.test.ts` (`mcp`) walks the |
| 72 | + * `McpDataBridge` write faces. |
| 73 | + * |
| 74 | + * Together they assert the PROPERTY — "no response body an external caller |
| 75 | + * receives from a write carries an `internal: true` value" — rather than the |
| 76 | + * shape of any one class. Adding a write mouth? Route its response records |
| 77 | + * through this helper and register it with the tripwire that enumerates its |
| 78 | + * surface. |
| 79 | + * |
| 80 | + * ## Semantics |
| 81 | + * |
| 82 | + * Mirrors the engine's `collectInternalReadFields` rule exactly — a field |
| 83 | + * participates iff its declaration carries `internal === true` (strict |
| 84 | + * boolean; truthy strings and numbers do not count, same as the engine). |
| 85 | + * `@objectstack/core` cannot import that collector (`@objectstack/objectql` |
| 86 | + * sits above this package), so the rule is restated here in full; |
| 87 | + * `internal-fields.test.ts` in objectql and the tripwires above pin the same |
| 88 | + * spelling from both sides. OMIT, not mask, for the #7728 reasons: the flag's |
| 89 | + * columns are `required`, so a mask carries zero bits while still shipping a |
| 90 | + * value under a field whose description promises none. |
| 91 | + * |
| 92 | + * Deletion is IN PLACE and idempotent: records that already lack the field |
| 93 | + * (a re-stripped read result, a fake engine that never returned it) pass |
| 94 | + * through unchanged, and non-record values (`null`, an affected-row count, a |
| 95 | + * driver's boolean delete verdict) are skipped rather than judged. |
| 96 | + */ |
| 97 | + |
| 98 | +/** Minimal view of an object schema this module reads — the field map only. */ |
| 99 | +interface SchemaWithFields { |
| 100 | + fields?: Record<string, { internal?: unknown } | undefined> | undefined; |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Collect the names of fields declared `internal: true` on `schema`. |
| 105 | + * |
| 106 | + * Same verdicts as objectql's `collectInternalReadFields` (see the module |
| 107 | + * header for why it is restated rather than imported): strict `=== true`, |
| 108 | + * empty result for a missing/field-less schema. |
| 109 | + */ |
| 110 | +export function collectInternalWriteResponseFields(schema: unknown): string[] { |
| 111 | + const fields = (schema as SchemaWithFields | null | undefined)?.fields; |
| 112 | + if (!fields || typeof fields !== 'object') return []; |
| 113 | + const out: string[] = []; |
| 114 | + for (const [name, def] of Object.entries(fields)) { |
| 115 | + if (def && def.internal === true) out.push(name); |
| 116 | + } |
| 117 | + return out; |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Drop every `internal: true` field from a write response's record(s), in |
| 122 | + * place. THE single helper every external write mouth goes through — see the |
| 123 | + * module header; the three tripwires enforce the "every". |
| 124 | + * |
| 125 | + * @param schema The registered object schema (`engine.registry.getObject(...)` |
| 126 | + * / the protocol's own registry view / `metadataService |
| 127 | + * .getObject(...)`). An unknown object (no schema) strips |
| 128 | + * nothing — the write itself would have been refused upstream |
| 129 | + * by the object-existence gate. |
| 130 | + * @param records A single record, an array of records, or anything a write |
| 131 | + * mouth hands back where a record could sit (`null`, a count, a |
| 132 | + * boolean): non-objects are skipped, arrays are walked. |
| 133 | + */ |
| 134 | +export function omitInternalFieldsFromWriteResponse(schema: unknown, records: unknown): void { |
| 135 | + if (!records) return; |
| 136 | + const internalFields = collectInternalWriteResponseFields(schema); |
| 137 | + if (internalFields.length === 0) return; |
| 138 | + const list = Array.isArray(records) ? records : [records]; |
| 139 | + for (const row of list) { |
| 140 | + if (!row || typeof row !== 'object') continue; |
| 141 | + for (const field of internalFields) delete (row as Record<string, unknown>)[field]; |
| 142 | + } |
| 143 | +} |
0 commit comments