@harperfast/harper 5.2.0-alpha.4 → 5.2.0-alpha.6

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.
Files changed (173) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dist/agent/agent.d.ts +24 -3
  28. package/dist/agent/agent.js +172 -10
  29. package/dist/agent/agent.js.map +1 -1
  30. package/dist/agent/bestPractices.d.ts +24 -0
  31. package/dist/agent/bestPractices.js +60 -0
  32. package/dist/agent/bestPractices.js.map +1 -0
  33. package/dist/agent/mcpTools.d.ts +24 -0
  34. package/dist/agent/mcpTools.js +115 -0
  35. package/dist/agent/mcpTools.js.map +1 -0
  36. package/dist/agent/operations.js +10 -1
  37. package/dist/agent/operations.js.map +1 -1
  38. package/dist/agent/registryTools.d.ts +45 -0
  39. package/dist/agent/registryTools.js +113 -0
  40. package/dist/agent/registryTools.js.map +1 -0
  41. package/dist/agent/session.js +16 -7
  42. package/dist/agent/session.js.map +1 -1
  43. package/dist/agent/tools/fsTools.js +70 -28
  44. package/dist/agent/tools/fsTools.js.map +1 -1
  45. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  46. package/dist/agent/tools/inspectorTool.js +428 -0
  47. package/dist/agent/tools/inspectorTool.js.map +1 -0
  48. package/dist/agent/toolset.d.ts +16 -6
  49. package/dist/agent/toolset.js +17 -7
  50. package/dist/agent/toolset.js.map +1 -1
  51. package/dist/agent/types.d.ts +2 -0
  52. package/dist/components/Application.d.ts +73 -18
  53. package/dist/components/Application.js +475 -64
  54. package/dist/components/Application.js.map +1 -1
  55. package/dist/components/OptionsWatcher.d.ts +1 -1
  56. package/dist/components/OptionsWatcher.js +65 -2
  57. package/dist/components/OptionsWatcher.js.map +1 -1
  58. package/dist/components/Scope.d.ts +8 -6
  59. package/dist/components/Scope.js +22 -6
  60. package/dist/components/Scope.js.map +1 -1
  61. package/dist/components/componentLoader.js +5 -1
  62. package/dist/components/componentLoader.js.map +1 -1
  63. package/dist/components/componentSecrets.d.ts +33 -7
  64. package/dist/components/componentSecrets.js +473 -59
  65. package/dist/components/componentSecrets.js.map +1 -1
  66. package/dist/components/deploymentRecorder.d.ts +2 -2
  67. package/dist/components/deploymentRecorder.js +1 -1
  68. package/dist/components/deploymentRecorder.js.map +1 -1
  69. package/dist/components/gitCredentialHelper.d.ts +1 -0
  70. package/dist/components/gitCredentialHelper.js +113 -0
  71. package/dist/components/gitCredentialHelper.js.map +1 -0
  72. package/dist/components/gitCredentialServer.d.ts +33 -0
  73. package/dist/components/gitCredentialServer.js +236 -0
  74. package/dist/components/gitCredentialServer.js.map +1 -0
  75. package/dist/components/mcp/tools/application.d.ts +19 -0
  76. package/dist/components/mcp/tools/application.js +180 -22
  77. package/dist/components/mcp/tools/application.js.map +1 -1
  78. package/dist/components/mcp/tools/operations.d.ts +13 -0
  79. package/dist/components/mcp/tools/operations.js +1 -0
  80. package/dist/components/mcp/tools/operations.js.map +1 -1
  81. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  82. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  83. package/dist/components/operations.js +23 -21
  84. package/dist/components/operations.js.map +1 -1
  85. package/dist/components/operationsValidation.js +84 -21
  86. package/dist/components/operationsValidation.js.map +1 -1
  87. package/dist/components/secretOperations.d.ts +33 -11
  88. package/dist/components/secretOperations.js +90 -26
  89. package/dist/components/secretOperations.js.map +1 -1
  90. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  91. package/dist/config/harperConfigEnvVars.js +95 -5
  92. package/dist/config/harperConfigEnvVars.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/resources/Resource.d.ts +19 -0
  97. package/dist/resources/Resource.js +93 -2
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/Table.d.ts +9 -0
  100. package/dist/resources/Table.js +256 -50
  101. package/dist/resources/Table.js.map +1 -1
  102. package/dist/resources/analytics/read.js +28 -25
  103. package/dist/resources/analytics/read.js.map +1 -1
  104. package/dist/resources/defineResource.d.ts +180 -0
  105. package/dist/resources/defineResource.js +505 -0
  106. package/dist/resources/defineResource.js.map +1 -0
  107. package/dist/resources/defineTable.d.ts +221 -0
  108. package/dist/resources/defineTable.js +227 -0
  109. package/dist/resources/defineTable.js.map +1 -0
  110. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  111. package/dist/resources/jsonSchemaTypes.js +12 -4
  112. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  113. package/dist/resources/openApi.js +69 -13
  114. package/dist/resources/openApi.js.map +1 -1
  115. package/dist/resources/search.js +6 -8
  116. package/dist/resources/search.js.map +1 -1
  117. package/dist/server/graphqlQuerying.js +4 -2
  118. package/dist/server/graphqlQuerying.js.map +1 -1
  119. package/dist/server/http.d.ts +12 -0
  120. package/dist/server/http.js +35 -15
  121. package/dist/server/http.js.map +1 -1
  122. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  123. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +23 -0
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/utility/errors/hdbError.d.ts +21 -0
  127. package/dist/utility/errors/hdbError.js +23 -1
  128. package/dist/utility/errors/hdbError.js.map +1 -1
  129. package/dist/validation/configValidator.js +22 -6
  130. package/dist/validation/configValidator.js.map +1 -1
  131. package/index.ts +33 -0
  132. package/npm-shrinkwrap.json +9197 -15236
  133. package/package.json +5 -3
  134. package/resources/DESIGN.md +42 -15
  135. package/resources/Resource.ts +99 -2
  136. package/resources/Table.ts +275 -71
  137. package/resources/analytics/read.ts +30 -25
  138. package/resources/defineResource.ts +651 -0
  139. package/resources/defineTable.ts +407 -0
  140. package/resources/jsonSchemaTypes.ts +12 -4
  141. package/resources/openApi.ts +68 -16
  142. package/resources/search.ts +5 -8
  143. package/server/graphqlQuerying.ts +4 -2
  144. package/server/http.ts +40 -16
  145. package/server/serverHelpers/serverUtilities.ts +19 -6
  146. package/server/serverHelpers/uwsServer.ts +24 -0
  147. package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
  148. package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
  149. package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
  150. package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
  151. package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
  152. package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
  153. package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
  154. package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
  155. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
  156. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
  157. package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
  158. package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
  159. package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
  160. package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
  161. package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
  162. package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
  163. package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
  164. package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
  165. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
  166. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
  167. package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
  168. package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
  169. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
  170. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
  171. package/studio/web/index.html +1 -1
  172. package/utility/errors/hdbError.ts +32 -0
  173. package/validation/configValidator.ts +23 -6
@@ -28,7 +28,14 @@ import { when, promiseNormalize } from '../utility/when.ts';
28
28
  import { DatabaseTransaction, ImmediateTransaction, TRANSACTION_STATE } from './DatabaseTransaction.ts';
29
29
  import * as envMngr from '../utility/environment/environmentManager.ts';
30
30
  import { addSubscription } from './transactionBroadcast.ts';
31
- import { handleHDBError, ClientError, ServerError, AccessViolation } from '../utility/errors/hdbError.ts';
31
+ import {
32
+ handleHDBError,
33
+ ClientError,
34
+ ServerError,
35
+ AccessViolation,
36
+ ValidationError,
37
+ type ValidationIssue,
38
+ } from '../utility/errors/hdbError.ts';
32
39
  import * as signalling from '../utility/signalling.ts';
33
40
  import { SchemaEventMsg, UserEventMsg } from '../server/threads/itc.js';
34
41
  import { databases, table } from './databases.ts';
@@ -152,6 +159,43 @@ export function freezeRecord(value: any): void {
152
159
  if (value !== null && typeof value === 'object' && !ArrayBuffer.isView(value) && !(value instanceof ArrayBuffer))
153
160
  Object.freeze(value);
154
161
  }
162
+ // Returns a read-only VIEW of `record` for evaluation (e.g. a record-scoped allowRead guard)
163
+ // without mutating the original. Locally-loaded records are already frozen by loadLocalRecord, so
164
+ // this is a no-op there; the source-revalidation path hands back the SAME object its deferred
165
+ // commit still writes to (createdAt/updatedAt) and persists, so that object can't be frozen
166
+ // directly. A write-blocking Proxy is used instead of a shallow copy: a copy would eagerly invoke
167
+ // every getter (breaking a caching table's lazy structPrototype decode) and silently drop
168
+ // non-enumerable properties like Array.length. Only ordinary objects/arrays are wrapped — Date,
169
+ // Map, Set, RegExp, etc. carry internal slots that throw "incompatible receiver" when their
170
+ // methods run against anything but the exact original instance (a Proxy included), so those are
171
+ // returned unwrapped rather than risk breaking an override that calls a method on one; a cached
172
+ // record CAN legitimately be one of these (getFromSource only requires `typeof === 'object'`).
173
+ export function frozenRecordView(record: any): any {
174
+ if (record === null || typeof record !== 'object' || ArrayBuffer.isView(record) || record instanceof ArrayBuffer)
175
+ return record;
176
+ if (Object.isFrozen(record)) return record;
177
+ const tag = Object.prototype.toString.call(record);
178
+ if (tag !== '[object Object]' && tag !== '[object Array]') return record;
179
+ return new Proxy(record, {
180
+ get(target, prop) {
181
+ // receiver = target (not this proxy) so a lazy-decode getter runs with `this` bound to
182
+ // the real record, matching its behavior when read directly.
183
+ return Reflect.get(target, prop, target);
184
+ },
185
+ set() {
186
+ return false;
187
+ },
188
+ defineProperty() {
189
+ return false;
190
+ },
191
+ deleteProperty() {
192
+ return false;
193
+ },
194
+ setPrototypeOf() {
195
+ return false;
196
+ },
197
+ });
198
+ }
155
199
  export const INVALIDATED = 1;
156
200
  export const EVICTED = 8; // note that 2 is reserved for timestamps
157
201
  const TEST_WRITE_KEY_BUFFER = Buffer.allocUnsafeSlow(8192);
@@ -163,6 +207,12 @@ const REPLAY_YIELD_INTERVAL = 100; // yield to the event loop every N records du
163
207
  // buffer the entire backward chain per record, synchronously, on every worker — pinning the JS heap
164
208
  // until the worker OOMs (issue #1114). Beyond this depth we fall back to a bounded reconciliation.
165
209
  const MAX_OUT_OF_ORDER_AUDIT_DEPTH = 1000;
210
+ // Cap on audit records inspected while backfilling a subscription's `previousCount` history,
211
+ // independent of `count` (the number of AUTHORIZED events collected). `count` only decrements on
212
+ // records that pass the row-level allowRead filter, so an all-deny override would otherwise force
213
+ // a full walk of the retained audit log (#1786) — this bounds that walk regardless of how many
214
+ // records the filter accepts.
215
+ const MAX_PREVIOUS_COUNT_SCAN = 10_000;
166
216
  const FULL_PERMISSIONS = {
167
217
  read: true,
168
218
  insert: true,
@@ -2640,17 +2690,42 @@ export function makeTable(options) {
2640
2690
  const txn = txnForContext(context);
2641
2691
  if (!target) throw new Error('No query provided');
2642
2692
  if (target.parseError) throw target.parseError; // if there was a parse error, we can throw it now
2693
+ // An application-overridden allowRead is a RECORD-scoped check (#1422 gap 2 / #1241): it is
2694
+ // evaluated once per record during query execution with `this` = the record, instead of once
2695
+ // at entry where `this` is a collection resource with nothing loaded. The framework defaults
2696
+ // (marked isDefaultAllowRead) are this-free table/RBAC checks and keep the entry evaluation.
2697
+ // Record-scoping is SYNC-only: async-declared overrides keep entry-check semantics (the
2698
+ // authorize wrapper awaits them and fails closed) rather than entering the sync traversal.
2699
+ const recordScopedAllowRead =
2700
+ target.checkPermission &&
2701
+ !(this.allowRead as any)?.isDefaultAllowRead &&
2702
+ (this.allowRead as any)?.constructor?.name !== 'AsyncFunction'
2703
+ ? this.allowRead
2704
+ : undefined;
2643
2705
  if (target.checkPermission) {
2644
- // requesting authorization verification
2645
- let allowed;
2646
- try {
2647
- allowed = this.allowRead((context as any).user, target, context);
2648
- } catch {
2649
- // allow* threw fail closed rather than letting the request proceed
2650
- throw new AccessViolation((context as any).user);
2651
- }
2652
- if (!allowed) {
2653
- throw new AccessViolation((context as any).user);
2706
+ if (recordScopedAllowRead) {
2707
+ // Compose with role-level column RBAC: the DEFAULT table allowRead is also the
2708
+ // enforcement point for attribute_permissions (it narrows target.select). Run it here
2709
+ // for that side effect before `select` is captured below — with its verdict
2710
+ // superseded by the per-record override. A per-record super.allowRead call could NOT
2711
+ // restore this: the select-driven output transform is built before iteration starts.
2712
+ try {
2713
+ TableResource.prototype.allowRead.call(this, (context as any).user, target, context);
2714
+ } catch {
2715
+ // narrowing is best-effort under an override; access is governed per record
2716
+ }
2717
+ } else {
2718
+ // requesting authorization verification
2719
+ let allowed;
2720
+ try {
2721
+ allowed = this.allowRead((context as any).user, target, context);
2722
+ } catch {
2723
+ // allow* threw — fail closed rather than letting the request proceed
2724
+ throw new AccessViolation((context as any).user);
2725
+ }
2726
+ if (!allowed) {
2727
+ throw new AccessViolation((context as any).user);
2728
+ }
2654
2729
  }
2655
2730
  }
2656
2731
  if (context) context.lastModified = UNCACHEABLE_TIMESTAMP;
@@ -2854,16 +2929,46 @@ export function makeTable(options) {
2854
2929
  // scans), the read transaction reads against the latest committed data without pinning a
2855
2930
  // consistent snapshot, so the scan doesn't hold a snapshot that blocks compaction.
2856
2931
  const readTxn = txn.useReadTxn(target.snapshot === false);
2857
- // Record-level read guard (#1241): a resource may define a static allowReadRecord(user, record)
2858
- // that participates in query execution (pushed into HNSW traversal for vector sorts, applied as a
2859
- // post-filter otherwise). Resolve off the actual (possibly subclassed) constructor so overrides win.
2860
- // SCOPE: this is a QUERY-result filter, not a general read-authorization boundary direct
2861
- // single-record get(id) does not consult it (use allowRead/instance hooks for that). It must be
2862
- // synchronous, side-effect free, and fast; it can run once per candidate record during traversal.
2863
- const allowReadRecord = (this.constructor as any).allowReadRecord;
2932
+ // Record-level allowRead guard (#1241/#1422): an application-overridden allowRead runs once
2933
+ // per record with `this` = the (frozen) record, participating in query execution pushed
2934
+ // into HNSW traversal for vector sorts, applied as a post-filter otherwise. It must be
2935
+ // synchronous, side-effect free, and fast; a throw denies that record (fail closed, #1422
2936
+ // gap 1 parity). Dispatched via the method resolved from the resource never via a
2937
+ // `record.allowRead` property lookup, so a record attribute named allowRead can't shadow it.
2938
+ let recordGuard: ((record: any) => boolean) | undefined;
2939
+ if (recordScopedAllowRead) {
2940
+ const user = (context as any)?.user;
2941
+ let warnedAsync = false;
2942
+ recordGuard = (record: any) => {
2943
+ let allowed;
2944
+ try {
2945
+ allowed = recordScopedAllowRead.call(record, user, target, context);
2946
+ } catch {
2947
+ return false; // fail closed on a throwing check
2948
+ }
2949
+ if (typeof allowed?.then === 'function') {
2950
+ // A sync-declared override that returns a thenable can't be awaited mid-traversal:
2951
+ // deny the record (fail closed, #1422 gap 1 semantics) rather than fail open on
2952
+ // promise truthiness or abort the whole query. Declared-async overrides never get
2953
+ // here — they keep the awaited entry check. We're not observing this rejection, so
2954
+ // attach a no-op handler — otherwise a rejected thenable reaches the global
2955
+ // unhandledRejection logger once per denied candidate. Use `.then(undefined, ...)`,
2956
+ // not `.catch` — a thenable is only guaranteed to have `.then`.
2957
+ allowed.then(undefined, () => {});
2958
+ if (!warnedAsync) {
2959
+ warnedAsync = true;
2960
+ logger.warn?.(
2961
+ `allowRead on ${tableName} returned a promise during per-record evaluation; records are denied (record-scoped allowRead must be synchronous)`
2962
+ );
2963
+ }
2964
+ return false;
2965
+ }
2966
+ return Boolean(allowed);
2967
+ };
2968
+ }
2864
2969
  const recordAccess =
2865
- typeof allowReadRecord === 'function' || typeof target.vectorFilter === 'function'
2866
- ? { allowReadRecord, user: (context as any)?.user, vectorFilter: target.vectorFilter }
2970
+ recordGuard || typeof target.vectorFilter === 'function'
2971
+ ? { recordGuard, vectorFilter: target.vectorFilter }
2867
2972
  : undefined;
2868
2973
  const entries = executeConditions(
2869
2974
  conditions,
@@ -2877,16 +2982,13 @@ export function makeTable(options) {
2877
2982
  recordAccess
2878
2983
  );
2879
2984
  const ensure_loaded = (target as any).ensureLoaded !== false;
2880
- // Authoritative RBAC enforcement (#1241): the guards inside executeConditions evaluate the LOCAL
2881
- // record, but on a caching table transformEntryForSelect may then revalidate an expired/invalidated
2882
- // row from source and return a DIFFERENT record. An authorization check must hold on the record
2883
- // actually returned, so allowReadRecord is re-checked there, after materialization (the earlier
2884
- // evaluation stays as a prune that also bounds HNSW traversal). vectorFilter and condition filters
2885
- // intentionally keep the local-record semantics all query filters have on caching tables.
2886
- const recordGuard =
2887
- typeof allowReadRecord === 'function'
2888
- ? (record: any) => allowReadRecord((context as any)?.user, record)
2889
- : undefined;
2985
+ // Authoritative enforcement point (#1241): the guards inside executeConditions evaluate the
2986
+ // LOCAL record, but on a caching table transformEntryForSelect may then revalidate an
2987
+ // expired/invalidated row from source and return a DIFFERENT record. An authorization check
2988
+ // must hold on the record actually returned, so the record-scoped allowRead is re-checked
2989
+ // there, after materialization (the earlier evaluation stays as a prune that also bounds HNSW
2990
+ // traversal). vectorFilter and condition filters intentionally keep the local-record
2991
+ // semantics all query filters have on caching tables.
2890
2992
  const transformToRecord = TableResource.transformEntryForSelect(
2891
2993
  select,
2892
2994
  context,
@@ -3192,8 +3294,10 @@ export function makeTable(options) {
3192
3294
  if (record == null) return canSkip ? SKIP : record;
3193
3295
  // Record-level RBAC (#1241): enforced here because `record` is now the final, materialized
3194
3296
  // record — a caching table's source revalidation (above) may have replaced the local copy the
3195
- // query filters evaluated. Fail closed on the record actually being returned.
3196
- if (recordGuard && !recordGuard(record)) return canSkip ? SKIP : undefined;
3297
+ // query filters evaluated. Fail closed on the record actually being returned. The guard sees a
3298
+ // frozen view (frozenRecordView) so an allowRead override that writes through `this` can't
3299
+ // mutate a record the source-revalidation path's deferred commit still needs to write and encode.
3300
+ if (recordGuard && !recordGuard(frozenRecordView(record))) return canSkip ? SKIP : undefined;
3197
3301
  if (select && !(select[0] === '*' && select.length === 1)) {
3198
3302
  let promises: Promise<any>[];
3199
3303
  const selectAttribute = (attribute, callback) => {
@@ -3362,6 +3466,57 @@ export function makeTable(options) {
3362
3466
  let reloadResnapshotRunning = false;
3363
3467
  let reloadResnapshotPending = false;
3364
3468
  const thisId = requestTargetToId(request) ?? null; // treat undefined and null as the root
3469
+ // Record-scoped allowRead on subscription delivery (#1419, reviving #1524 on the unified
3470
+ // model): the subscribe entry check already granted the connection (topic ACL / collection
3471
+ // scope); here we ADDITIONALLY re-evaluate a sync application-overridden allowRead per
3472
+ // record-bearing event with `this` = the event's record, the delivery-path analog of the
3473
+ // per-record query guard. Gated to (1) an overridden allowRead (the default is
3474
+ // record-independent and already enforced at connect, so the common case stays zero-overhead)
3475
+ // and (2) an authorization-checked subscription — the entry check clears checkPermission
3476
+ // before we get here and an anonymous-but-checked subscription has no user to key on, so the
3477
+ // wrapper stamps a durable `rowLevelAuthChecked` flag; internal subscribers (replication,
3478
+ // system watchers) never set it and keep full delivery.
3479
+ // Fails closed: a throwing or thenable-returning override drops the event rather than leaks it.
3480
+ //
3481
+ // Known limitation (as in #1524): only put/invalidate events carry the authoritative full
3482
+ // record. delete (tombstone, null value), message payloads, and rawEvents partial values may
3483
+ // mis-decide an override keyed on row fields; closing the primary leak (record updates) is
3484
+ // the goal here — authorizing non-record-bearing event types against the full row is deferred.
3485
+ const subContext = this.getContext() as any;
3486
+ const subAllowRead = this.allowRead;
3487
+ const filterRowReads = (request as any)?.rowLevelAuthChecked && !(subAllowRead as any)?.isDefaultAllowRead;
3488
+ let warnedAsyncEvent = false;
3489
+ const allowsEvent = filterRowReads
3490
+ ? (event: any): boolean => {
3491
+ if (event.type === 'end_txn' || event.type === 'reload') return true; // control markers, no record
3492
+ // Freeze the record before the override sees it, mirroring the query path — event.value
3493
+ // is the shared primaryStore object, so an override that writes to `this` would otherwise
3494
+ // mutate the cache for every reader on the node.
3495
+ if (event.value) freezeRecord(event.value);
3496
+ let decision;
3497
+ try {
3498
+ // Evaluate against the LIVE context user, not a subscribe-time snapshot: #1414 re-auth
3499
+ // updates subContext.user in place, so a role/claims change must be reflected per event.
3500
+ decision = subAllowRead.call(event.value ?? null, subContext.user, request, subContext);
3501
+ } catch {
3502
+ return false; // fail closed: a throwing override drops the event
3503
+ }
3504
+ if (decision != null && typeof decision.then === 'function') {
3505
+ // Not observing this rejection — attach a no-op handler so it doesn't reach the
3506
+ // global unhandledRejection logger once per dropped event. Use `.then(undefined,
3507
+ // ...)`, not `.catch` — a thenable is only guaranteed to have `.then`.
3508
+ decision.then(undefined, () => {});
3509
+ if (!warnedAsyncEvent) {
3510
+ warnedAsyncEvent = true;
3511
+ logger.warn?.(
3512
+ `allowRead on ${tableName} returned a promise during subscription event evaluation; events are dropped (record-scoped allowRead must be synchronous)`
3513
+ );
3514
+ }
3515
+ return false;
3516
+ }
3517
+ return Boolean(decision);
3518
+ }
3519
+ : null;
3365
3520
  const subscription = addSubscription(
3366
3521
  TableResource,
3367
3522
  thisId,
@@ -3403,8 +3558,11 @@ export function makeTable(options) {
3403
3558
  type,
3404
3559
  beginTxn,
3405
3560
  };
3561
+ // Queued events are filtered when the queue drains through send() below; events sent
3562
+ // directly (queue already drained) are filtered here. Each event is filtered once.
3406
3563
  if (pendingRealTimeQueue) pendingRealTimeQueue.push(event);
3407
3564
  else {
3565
+ if (allowsEvent && !allowsEvent(event)) return;
3408
3566
  if (databaseName !== 'system') {
3409
3567
  recordAction(auditRecord.size ?? 1, 'db-message', tableName, null);
3410
3568
  }
@@ -3479,24 +3637,44 @@ export function makeTable(options) {
3479
3637
  }
3480
3638
  } else if (count) {
3481
3639
  const history = [];
3640
+ let inspected = 0;
3482
3641
  // we are collecting the history in reverse order to get the right count, then reversing to send
3483
3642
  for (const auditRecord of auditStore.getRange({ start: 'z', end: false, reverse: true })) {
3484
3643
  if (++recordsSinceYield >= REPLAY_YIELD_INTERVAL) {
3485
3644
  recordsSinceYield = 0;
3486
3645
  await rest();
3646
+ // Subscription.end() nulls `subscriptions` on close; stop backfilling a dead
3647
+ // subscriber rather than continuing to walk the retained audit log.
3648
+ if (!subscription.subscriptions) break;
3487
3649
  }
3488
3650
  try {
3489
3651
  if (auditRecord.tableId !== tableId) continue;
3490
3652
  const id = auditRecord.recordId;
3491
3653
  if (thisId == null || isDescendantId(thisId, id)) {
3654
+ // Bound entries INSPECTED for THIS scope, independent of `count` (entries
3655
+ // AUTHORIZED) — an all-deny allowRead override must not force a full walk of
3656
+ // the retained audit log (#1786), even though it returns fewer than `count`
3657
+ // authorized events. Counted only once a record is known to be in scope (right
3658
+ // table, right id) so unrelated cross-table/cross-scope audit traffic in a busy
3659
+ // shared log can't spuriously cut a backfill short.
3660
+ if (++inspected > MAX_PREVIOUS_COUNT_SCAN) {
3661
+ logger.warn?.(
3662
+ `previousCount backfill on ${tableName} stopped after inspecting ${MAX_PREVIOUS_COUNT_SCAN} in-scope audit records without collecting ${request.previousCount} authorized event(s); returning ${history.length} instead`
3663
+ );
3664
+ break;
3665
+ }
3492
3666
  const value = auditRecord.getValue(primaryStore, getFullRecord, auditRecord.localTime);
3493
- history.push({
3667
+ const historyEntry = {
3494
3668
  id,
3495
3669
  localTime: auditRecord.localTime,
3496
3670
  value,
3497
3671
  version: auditRecord.version,
3498
3672
  type: auditRecord.type,
3499
- });
3673
+ };
3674
+ // Filter denied rows BEFORE they consume a previousCount slot, so an authorized
3675
+ // subscriber still receives up to `count` readable events (#1419).
3676
+ if (allowsEvent && !allowsEvent(historyEntry)) continue;
3677
+ history.push(historyEntry);
3500
3678
  if (--count <= 0) break;
3501
3679
  }
3502
3680
  } catch (error) {
@@ -3638,6 +3816,8 @@ export function makeTable(options) {
3638
3816
  subscription.send(error);
3639
3817
  });
3640
3818
  function send(event: any) {
3819
+ // Covers the pendingRealTimeQueue drain and the reload re-snapshot (#495) delivery.
3820
+ if (allowsEvent && !allowsEvent(event)) return;
3641
3821
  if (databaseName !== 'system') {
3642
3822
  recordAction(event.size ?? 1, 'db-message', tableName, null);
3643
3823
  }
@@ -3852,13 +4032,21 @@ export function makeTable(options) {
3852
4032
  }
3853
4033
  // #section: validation
3854
4034
  validate(record: any, patch?: boolean) {
3855
- let validationErrors;
4035
+ // Accumulate structured per-field issues so the 400 carries `{ path, code,
4036
+ // message }[]` matching the emitted OpenAPI, instead of a single joined string. The joined
4037
+ // message is still built for the HTTP title, preserving back-compat for callers that read it.
4038
+ let validationErrors: ValidationIssue[] | undefined;
4039
+ const addError = (path: string, code: string, message: string) => {
4040
+ (validationErrors || (validationErrors = [])).push({ path, code, message });
4041
+ };
3856
4042
  const validateValue = (value, attribute: Attribute, name) => {
3857
4043
  if (attribute.type && value != null) {
3858
4044
  if (patch && value.__op__) value = value.value;
3859
4045
  if (attribute.properties) {
3860
4046
  if (typeof value !== 'object') {
3861
- (validationErrors || (validationErrors = [])).push(
4047
+ addError(
4048
+ name,
4049
+ 'type',
3862
4050
  `Value ${stringify(value)} in property ${name} must be an object${
3863
4051
  attribute.type ? ' (' + attribute.type + ')' : ''
3864
4052
  }`
@@ -3869,7 +4057,9 @@ export function makeTable(options) {
3869
4057
  const attribute = properties[i];
3870
4058
  if (attribute.relationship || attribute.computed) {
3871
4059
  if (record.hasOwnProperty(attribute.name)) {
3872
- (validationErrors || (validationErrors = [])).push(
4060
+ addError(
4061
+ `${name}.${attribute.name}`,
4062
+ 'computed',
3873
4063
  `Computed property ${name}.${attribute.name} may not be directly assigned a value`
3874
4064
  );
3875
4065
  }
@@ -3881,7 +4071,9 @@ export function makeTable(options) {
3881
4071
  if (attribute.sealed && value != null && typeof value === 'object') {
3882
4072
  for (const key in value) {
3883
4073
  if (!properties.find((property) => property.name === key)) {
3884
- (validationErrors || (validationErrors = [])).push(
4074
+ addError(
4075
+ `${name}.${key}`,
4076
+ 'unknown_property',
3885
4077
  `Property ${key} is not allowed within object in property ${name}`
3886
4078
  );
3887
4079
  }
@@ -3891,13 +4083,17 @@ export function makeTable(options) {
3891
4083
  switch (attribute.type) {
3892
4084
  case 'Int':
3893
4085
  if (typeof value !== 'number' || value >> 0 !== value)
3894
- (validationErrors || (validationErrors = [])).push(
4086
+ addError(
4087
+ name,
4088
+ 'type',
3895
4089
  `Value ${stringify(value)} in property ${name} must be an integer (from -2147483648 to 2147483647)`
3896
4090
  );
3897
4091
  break;
3898
4092
  case 'Long':
3899
4093
  if (typeof value !== 'number' || !(Math.floor(value) === value && Math.abs(value) <= 9007199254740992))
3900
- (validationErrors || (validationErrors = [])).push(
4094
+ addError(
4095
+ name,
4096
+ 'type',
3901
4097
  `Value ${stringify(
3902
4098
  value
3903
4099
  )} in property ${name} must be an integer (from -9007199254740992 to 9007199254740992)`
@@ -3905,53 +4101,46 @@ export function makeTable(options) {
3905
4101
  break;
3906
4102
  case 'Float':
3907
4103
  if (typeof value !== 'number')
3908
- (validationErrors || (validationErrors = [])).push(
3909
- `Value ${stringify(value)} in property ${name} must be a number`
3910
- );
4104
+ addError(name, 'type', `Value ${stringify(value)} in property ${name} must be a number`);
3911
4105
  break;
3912
4106
  case 'ID':
3913
4107
  if (!(
3914
4108
  typeof value === 'string' ||
3915
4109
  (value?.length > 0 && value.every?.((value) => typeof value === 'string'))
3916
4110
  ))
3917
- (validationErrors || (validationErrors = [])).push(
4111
+ addError(
4112
+ name,
4113
+ 'type',
3918
4114
  `Value ${stringify(value)} in property ${name} must be a string, or an array of strings`
3919
4115
  );
3920
4116
  break;
3921
4117
  case 'String':
3922
4118
  if (typeof value !== 'string')
3923
- (validationErrors || (validationErrors = [])).push(
3924
- `Value ${stringify(value)} in property ${name} must be a string`
3925
- );
4119
+ addError(name, 'type', `Value ${stringify(value)} in property ${name} must be a string`);
3926
4120
  break;
3927
4121
  case 'Boolean':
3928
4122
  if (typeof value !== 'boolean')
3929
- (validationErrors || (validationErrors = [])).push(
3930
- `Value ${stringify(value)} in property ${name} must be a boolean`
3931
- );
4123
+ addError(name, 'type', `Value ${stringify(value)} in property ${name} must be a boolean`);
3932
4124
  break;
3933
4125
  case 'Date':
3934
4126
  if (!(value instanceof Date)) {
3935
4127
  if (typeof value === 'string' || typeof value === 'number') return new Date(value);
3936
- else
3937
- (validationErrors || (validationErrors = [])).push(
3938
- `Value ${stringify(value)} in property ${name} must be a Date`
3939
- );
4128
+ else addError(name, 'type', `Value ${stringify(value)} in property ${name} must be a Date`);
3940
4129
  }
3941
4130
  break;
3942
4131
  case 'BigInt':
3943
4132
  if (typeof value !== 'bigint') {
3944
4133
  // do coercion because otherwise it is rather difficult to get numbers to consistently be bigints
3945
4134
  if (typeof value === 'string' || typeof value === 'number') return BigInt(value);
3946
- (validationErrors || (validationErrors = [])).push(
3947
- `Value ${stringify(value)} in property ${name} must be a bigint`
3948
- );
4135
+ addError(name, 'type', `Value ${stringify(value)} in property ${name} must be a bigint`);
3949
4136
  }
3950
4137
  break;
3951
4138
  case 'Bytes':
3952
4139
  if (!(value instanceof Uint8Array)) {
3953
4140
  if (typeof value === 'string') return Buffer.from(value);
3954
- (validationErrors || (validationErrors = [])).push(
4141
+ addError(
4142
+ name,
4143
+ 'type',
3955
4144
  `Value ${stringify(value)} in property ${name} must be a Buffer or Uint8Array`
3956
4145
  );
3957
4146
  }
@@ -3962,9 +4151,7 @@ export function makeTable(options) {
3962
4151
  if (value instanceof Buffer) {
3963
4152
  return createBlob(value, { type: 'text/plain' });
3964
4153
  }
3965
- (validationErrors || (validationErrors = [])).push(
3966
- `Value ${stringify(value)} in property ${name} must be a Blob`
3967
- );
4154
+ addError(name, 'type', `Value ${stringify(value)} in property ${name} must be a Blob`);
3968
4155
  }
3969
4156
  break;
3970
4157
  case 'array':
@@ -3976,26 +4163,23 @@ export function makeTable(options) {
3976
4163
  if (updated) value[i] = updated;
3977
4164
  }
3978
4165
  }
3979
- } else
3980
- (validationErrors || (validationErrors = [])).push(
3981
- `Value ${stringify(value)} in property ${name} must be an Array`
3982
- );
4166
+ } else addError(name, 'type', `Value ${stringify(value)} in property ${name} must be an Array`);
3983
4167
 
3984
4168
  break;
3985
4169
  }
3986
4170
  }
3987
4171
  }
3988
4172
  if (attribute.nullable === false && value == null) {
3989
- (validationErrors || (validationErrors = [])).push(
3990
- `Property ${name} is required (and not does not allow null values)`
3991
- );
4173
+ addError(name, 'required', `Property ${name} is required (and not does not allow null values)`);
3992
4174
  }
3993
4175
  };
3994
4176
  for (let i = 0, l = attributes.length; i < l; i++) {
3995
4177
  const attribute = attributes[i];
3996
4178
  if (attribute.relationship || attribute.computed) {
3997
4179
  if (Object.hasOwn(record, attribute.name)) {
3998
- (validationErrors || (validationErrors = [])).push(
4180
+ addError(
4181
+ attribute.name,
4182
+ 'computed',
3999
4183
  `Computed property ${attribute.name} may not be directly assigned a value`
4000
4184
  );
4001
4185
  }
@@ -4009,13 +4193,13 @@ export function makeTable(options) {
4009
4193
  if (sealed) {
4010
4194
  for (const key in record) {
4011
4195
  if (!attributes.find((attribute) => attribute.name === key)) {
4012
- (validationErrors || (validationErrors = [])).push(`Property ${key} is not allowed`);
4196
+ addError(key, 'unknown_property', `Property ${key} is not allowed`);
4013
4197
  }
4014
4198
  }
4015
4199
  }
4016
4200
 
4017
4201
  if (validationErrors) {
4018
- throw new ClientError(validationErrors.join('. '));
4202
+ throw new ValidationError(validationErrors, validationErrors.map((issue) => issue.message).join('. '));
4019
4203
  }
4020
4204
  }
4021
4205
  // #section: stats-admin
@@ -4503,6 +4687,26 @@ export function makeTable(options) {
4503
4687
  }
4504
4688
  );
4505
4689
 
4690
+ // Mark the table-level allowRead as a framework default (it is a this-free table/RBAC check):
4691
+ // only an APPLICATION override is record-scoped and evaluated per record during query execution
4692
+ // (#1422 gap 2 / #1241). supportsRowLevelAllowRead tells the authorization wrapper in
4693
+ // Resource.ts that this class has the per-record machinery to defer collection reads to.
4694
+ (TableResource.prototype.allowRead as any).isDefaultAllowRead = true;
4695
+ (TableResource as any).supportsRowLevelAllowRead = true;
4696
+ // Records can be asked directly (`record.allowRead(user, target, context)`), delegating to the
4697
+ // table's allowRead with `this` = the record. Non-enumerable so it never serializes; defined on
4698
+ // the per-table record prototype alongside computed properties. Engine-level enforcement does
4699
+ // NOT route through this (it resolves the active resource class's allowRead, which honors
4700
+ // endpoint subclass overrides and can't be shadowed by a record attribute of the same name).
4701
+ if (primaryStore?.encoder?.structPrototype) {
4702
+ Object.defineProperty(primaryStore.encoder.structPrototype, 'allowRead', {
4703
+ value: function (user: User, target: RequestTarget, context: Context) {
4704
+ return TableResource.prototype.allowRead.call(this, user, target, context);
4705
+ },
4706
+ configurable: true,
4707
+ writable: true,
4708
+ });
4709
+ }
4506
4710
  TableResource.updatedAttributes(); // on creation, update accessors as well
4507
4711
  if (expirationMs) TableResource.setTTLExpiration(expirationMs / 1000);
4508
4712
  if (expiresAtProperty) runRecordExpirationEviction();
@@ -192,47 +192,52 @@ interface GetAnalyticsOpts {
192
192
 
193
193
  export async function get(metric: string, opts?: GetAnalyticsOpts): Promise<Metric[]> {
194
194
  const { getAttributes, startTime, endTime, additionalConditions, log: logName } = opts ?? {};
195
- const conditions: Conditions = [{ attribute: 'metric', comparator: 'equals', value: metric }];
195
+
196
+ // A two-sided time window bounds the primary-key (`id` = [time, nodeId]) range on both ends. When we
197
+ // have one, lead with it and pin execution order (below) so it drives the scan. `Table.search`
198
+ // otherwise reorders conditions by estimated cost, but the planner estimates a primary-key `between`
199
+ // as a flat 10% of the table (search.ts BETWEEN_ESTIMATE) rather than from the actual range width —
200
+ // so on a large `hdb_analytics` table a narrow window looks costlier than `metric equals`, iteration
201
+ // is driven off the metric index, and the metric's entire history is decoded instead of the window
202
+ // (#1796). The proper long-term fix is a range-width-aware cost estimate in search.ts.
203
+ const boundedWindow = startTime != null && endTime != null;
204
+ const conditions: Conditions = [];
205
+ if (boundedWindow) {
206
+ conditions.push({ attribute: 'id', comparator: 'between', value: [startTime, endTime] });
207
+ }
208
+ conditions.push({ attribute: 'metric', comparator: 'equals', value: metric });
196
209
  if (logName !== undefined) {
197
210
  conditions.push({ attribute: 'log', comparator: 'equals', value: logName });
198
211
  }
199
212
  if (additionalConditions) {
200
213
  conditions.push(...additionalConditions.map(conformCondition));
201
214
  }
215
+ // An open-ended range (only start or only end) can span the whole table, so we append it and leave
216
+ // ordering to the planner rather than forcing an unbounded range to drive — which could scan the
217
+ // full table for a selective metric.
218
+ if (!boundedWindow) {
219
+ if (startTime != null) {
220
+ conditions.push({ attribute: 'id', comparator: 'greater_than_equal', value: startTime });
221
+ }
222
+ if (endTime != null) {
223
+ conditions.push({ attribute: 'id', comparator: 'less_than', value: endTime });
224
+ }
225
+ }
226
+
202
227
  const select = getAttributes ?? [];
203
228
 
204
- // ensure we're always selecting id
229
+ // ensure we're always selecting id (an empty select array already selects everything, including id)
205
230
  if (!isSelected(select, 'id')) {
206
231
  select.push('id');
207
232
  }
208
233
 
209
- if (startTime && endTime) {
210
- conditions.push({
211
- attribute: 'id',
212
- comparator: 'between',
213
- value: [startTime, endTime],
214
- });
215
- } else {
216
- if (startTime) {
217
- conditions.push({
218
- attribute: 'id',
219
- comparator: 'greater_than_equal',
220
- value: startTime,
221
- });
222
- }
223
- if (endTime) {
224
- conditions.push({
225
- attribute: 'id',
226
- comparator: 'less_than',
227
- value: endTime,
228
- });
229
- }
230
- }
231
-
232
234
  // `snapshot: false` lets these (potentially long-running) analytics scans read against the
233
235
  // latest committed data without holding a consistent read snapshot open, so they stay easier
234
236
  // on the rest of the system (a pinned snapshot would block compaction for the scan's duration).
235
237
  const request: any = { conditions, allowConditionsOnDynamicAttributes: true, snapshot: false };
238
+ if (boundedWindow) {
239
+ request.enforceExecutionOrder = true;
240
+ }
236
241
  if (select.length > 0) {
237
242
  request['select'] = select;
238
243
  }