@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.
- package/agent/agent.ts +152 -10
- package/agent/bestPractices.ts +58 -0
- package/agent/mcpTools.ts +122 -0
- package/agent/operations.ts +10 -1
- package/agent/registryTools.ts +117 -0
- package/agent/session.ts +16 -7
- package/agent/tools/fsTools.ts +74 -27
- package/agent/tools/inspectorTool.ts +459 -0
- package/agent/toolset.ts +27 -7
- package/agent/types.ts +2 -0
- package/components/Application.ts +566 -85
- package/components/OptionsWatcher.ts +62 -2
- package/components/Scope.ts +31 -8
- package/components/componentLoader.ts +5 -1
- package/components/componentSecrets.ts +514 -55
- package/components/deploymentRecorder.ts +7 -6
- package/components/gitCredentialHelper.js +115 -0
- package/components/gitCredentialServer.ts +251 -0
- package/components/mcp/tools/application.ts +197 -23
- package/components/mcp/tools/operations.ts +1 -1
- package/components/mcp/tools/schemas/derive.ts +6 -2
- package/components/operations.js +21 -19
- package/components/operationsValidation.js +86 -21
- package/components/secretOperations.ts +110 -30
- package/config/harperConfigEnvVars.ts +90 -5
- package/config-root.schema.json +4 -0
- package/dist/agent/agent.d.ts +24 -3
- package/dist/agent/agent.js +172 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/bestPractices.d.ts +24 -0
- package/dist/agent/bestPractices.js +60 -0
- package/dist/agent/bestPractices.js.map +1 -0
- package/dist/agent/mcpTools.d.ts +24 -0
- package/dist/agent/mcpTools.js +115 -0
- package/dist/agent/mcpTools.js.map +1 -0
- package/dist/agent/operations.js +10 -1
- package/dist/agent/operations.js.map +1 -1
- package/dist/agent/registryTools.d.ts +45 -0
- package/dist/agent/registryTools.js +113 -0
- package/dist/agent/registryTools.js.map +1 -0
- package/dist/agent/session.js +16 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/tools/fsTools.js +70 -28
- package/dist/agent/tools/fsTools.js.map +1 -1
- package/dist/agent/tools/inspectorTool.d.ts +40 -0
- package/dist/agent/tools/inspectorTool.js +428 -0
- package/dist/agent/tools/inspectorTool.js.map +1 -0
- package/dist/agent/toolset.d.ts +16 -6
- package/dist/agent/toolset.js +17 -7
- package/dist/agent/toolset.js.map +1 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/components/Application.d.ts +73 -18
- package/dist/components/Application.js +475 -64
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +1 -1
- package/dist/components/OptionsWatcher.js +65 -2
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.d.ts +8 -6
- package/dist/components/Scope.js +22 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +5 -1
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/componentSecrets.d.ts +33 -7
- package/dist/components/componentSecrets.js +473 -59
- package/dist/components/componentSecrets.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +2 -2
- package/dist/components/deploymentRecorder.js +1 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/gitCredentialHelper.d.ts +1 -0
- package/dist/components/gitCredentialHelper.js +113 -0
- package/dist/components/gitCredentialHelper.js.map +1 -0
- package/dist/components/gitCredentialServer.d.ts +33 -0
- package/dist/components/gitCredentialServer.js +236 -0
- package/dist/components/gitCredentialServer.js.map +1 -0
- package/dist/components/mcp/tools/application.d.ts +19 -0
- package/dist/components/mcp/tools/application.js +180 -22
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.d.ts +13 -0
- package/dist/components/mcp/tools/operations.js +1 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/derive.js +6 -2
- package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
- package/dist/components/operations.js +23 -21
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +84 -21
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/secretOperations.d.ts +33 -11
- package/dist/components/secretOperations.js +90 -26
- package/dist/components/secretOperations.js.map +1 -1
- package/dist/config/harperConfigEnvVars.d.ts +21 -0
- package/dist/config/harperConfigEnvVars.js +95 -5
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/Resource.d.ts +19 -0
- package/dist/resources/Resource.js +93 -2
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.d.ts +9 -0
- package/dist/resources/Table.js +256 -50
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/read.js +28 -25
- package/dist/resources/analytics/read.js.map +1 -1
- package/dist/resources/defineResource.d.ts +180 -0
- package/dist/resources/defineResource.js +505 -0
- package/dist/resources/defineResource.js.map +1 -0
- package/dist/resources/defineTable.d.ts +221 -0
- package/dist/resources/defineTable.js +227 -0
- package/dist/resources/defineTable.js.map +1 -0
- package/dist/resources/jsonSchemaTypes.d.ts +2 -0
- package/dist/resources/jsonSchemaTypes.js +12 -4
- package/dist/resources/jsonSchemaTypes.js.map +1 -1
- package/dist/resources/openApi.js +69 -13
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/search.js +6 -8
- package/dist/resources/search.js.map +1 -1
- package/dist/server/graphqlQuerying.js +4 -2
- package/dist/server/graphqlQuerying.js.map +1 -1
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +35 -15
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +8 -5
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +23 -0
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +21 -0
- package/dist/utility/errors/hdbError.js +23 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/validation/configValidator.js +22 -6
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +33 -0
- package/npm-shrinkwrap.json +9197 -15236
- package/package.json +5 -3
- package/resources/DESIGN.md +42 -15
- package/resources/Resource.ts +99 -2
- package/resources/Table.ts +275 -71
- package/resources/analytics/read.ts +30 -25
- package/resources/defineResource.ts +651 -0
- package/resources/defineTable.ts +407 -0
- package/resources/jsonSchemaTypes.ts +12 -4
- package/resources/openApi.ts +68 -16
- package/resources/search.ts +5 -8
- package/server/graphqlQuerying.ts +4 -2
- package/server/http.ts +40 -16
- package/server/serverHelpers/serverUtilities.ts +19 -6
- package/server/serverHelpers/uwsServer.ts +24 -0
- package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
- package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
- package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
- package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
- package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
- package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
- package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
- package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
- package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
- package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
- package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
- package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
- package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
- package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
- package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/errors/hdbError.ts +32 -0
- package/validation/configValidator.ts +23 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harperfast/harper",
|
|
3
3
|
"description": "Harper is an open-source Node.js performance platform that unifies database, cache, application, and messaging layers into one in-memory process.",
|
|
4
|
-
"version": "5.2.0-alpha.
|
|
4
|
+
"version": "5.2.0-alpha.6",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://harper.fast",
|
|
7
7
|
"bugs": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"build:watch": "npm run build -- --watch --incremental",
|
|
57
57
|
"typecheck": "tsc --project tsconfig.json",
|
|
58
58
|
"typecheck:fast": "npx -y -p typescript@7.0.2 tsc --noEmit --project tsconfig.json",
|
|
59
|
+
"test:types": "tsc --project unitTests/types/tsconfig.json",
|
|
59
60
|
"package": "./build-tools/build.sh",
|
|
60
61
|
"lint": "oxlint --format stylish --deny-warnings .",
|
|
61
62
|
"lint:required": "oxlint --format stylish --quiet .",
|
|
@@ -166,7 +167,7 @@
|
|
|
166
167
|
},
|
|
167
168
|
"dependencies": {
|
|
168
169
|
"@aws-sdk/client-s3": "^3.1012.0",
|
|
169
|
-
"@aws-sdk/lib-storage": "3.
|
|
170
|
+
"@aws-sdk/lib-storage": "3.1080.0",
|
|
170
171
|
"@endo/static-module-record": "^1.1.2",
|
|
171
172
|
"@fastify/autoload": "^6.3.1",
|
|
172
173
|
"@fastify/compress": "^8.3.1",
|
|
@@ -174,6 +175,7 @@
|
|
|
174
175
|
"@fastify/static": "^9.1.3",
|
|
175
176
|
"@harperfast/extended-iterable": "^1.0.1",
|
|
176
177
|
"@harperfast/rocksdb-js": "^2.4.0",
|
|
178
|
+
"@harperfast/skills": "^1.10.8",
|
|
177
179
|
"@turf/area": "6.5.0",
|
|
178
180
|
"@turf/boolean-contains": "6.5.0",
|
|
179
181
|
"@turf/boolean-disjoint": "6.5.0",
|
|
@@ -198,7 +200,7 @@
|
|
|
198
200
|
"fast-glob": "3.3.3",
|
|
199
201
|
"fastify": "^5.8.2",
|
|
200
202
|
"fastify-plugin": "^5.1.0",
|
|
201
|
-
"fs-extra": "11.3.
|
|
203
|
+
"fs-extra": "11.3.6",
|
|
202
204
|
"graphql": "^16.10.0",
|
|
203
205
|
"graphql-http": "^1.22.4",
|
|
204
206
|
"gunzip-maybe": "1.4.2",
|
package/resources/DESIGN.md
CHANGED
|
@@ -25,6 +25,9 @@ See also: `../DESIGN.md` for cross-cutting non-obvious internals (RecordObject p
|
|
|
25
25
|
| `auditStore.ts` | Append-only audit log records |
|
|
26
26
|
| `nodeIdMapping.ts` | Maps node IDs ↔ timestamps for replication ordering |
|
|
27
27
|
| `openApi.ts` | Generates OpenAPI/JSON Schema from `@export` schemas |
|
|
28
|
+
| `defineTable.ts` | Code-first table authoring (`defineTable` + `types`) — a TS front-end to the canonical `table()` model |
|
|
29
|
+
| `defineResource.ts` | Per-method request contract (`defineResource` / `Resource.withSchema`, `t`, `schemaOf`) — typed handlers + edge validation |
|
|
30
|
+
| `jsonSchemaTypes.ts` | Shared `JsonSchemaFragment` IR + `attributeToFragment` projector (one vocabulary for validation/OpenAPI/MCP) |
|
|
28
31
|
| `analytics/` | Telemetry recording (separate from monitoring) |
|
|
29
32
|
|
|
30
33
|
---
|
|
@@ -75,21 +78,22 @@ One giant `makeTable()` factory that returns a `TableResource extends Resource`
|
|
|
75
78
|
|
|
76
79
|
## "Where is X" cheat sheet
|
|
77
80
|
|
|
78
|
-
| Question | Where
|
|
79
|
-
| --------------------------------------------------- |
|
|
80
|
-
| How is a CRUD request authorized? | `Table.ts → #section: authz-hooks`; defaults in `Resource.ts` (`allowRead` etc.)
|
|
81
|
-
| Where does versioning / conflict resolution happen? | `Table.ts → _writeUpdate` (`#section: write-path-internals`)
|
|
82
|
-
| How does `search()` choose an index? | `Table.ts → search` (`#section: search-query`)
|
|
83
|
-
| How are subscriptions replayed? | `Table.ts → subscribe` (`#section: pub-sub`)
|
|
84
|
-
| How is the response body shaped (select clause)? | `Table.ts → transformEntryForSelect` (`#section: search-query`)
|
|
85
|
-
| Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`)
|
|
86
|
-
| How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`)
|
|
87
|
-
| How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`)
|
|
88
|
-
| Where is the per-request transaction stored? | `transaction.ts` + `contextStorage` (AsyncLocalStorage)
|
|
89
|
-
| How does a query opt out of a read snapshot? | Pass `snapshot: false` on the search request (e.g. `get_analytics`). `Table.ts → search` calls `txn.useReadTxn(snapshot === false)`; on RocksDB `DatabaseTransaction.getReadTxn` then builds the read txn with `{ disableSnapshot: true }` so a long scan reads latest without pinning a snapshot. No-op on LMDB (`LMDBTransaction.useReadTxn`).
|
|
90
|
-
| How does a URL path map to a Resource? | `Resources.ts → getMatch` (exact/prefix fast path) then `matchParamRoute` (parameterised routes); see "Path routing" below
|
|
91
|
-
| How does HNSW keep the graph connected on delete? | `indexes/HierarchicalNavigableSmallWorld.ts → index()` delete path: zero-degree orphans reindexed via `needsReindexing`; severed multi-node islands detected and reconnected by `repairSeveredNeighbors` (#1712)
|
|
92
|
-
| How is a filter applied _during_ a vector search? | Predicate-aware traversal (#1241): `search.ts → executeConditions` composes companion AND conditions + a request `vectorFilter` + a
|
|
81
|
+
| Question | Where |
|
|
82
|
+
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| How is a CRUD request authorized? | `Table.ts → #section: authz-hooks`; defaults in `Resource.ts` (`allowRead` etc.) |
|
|
84
|
+
| Where does versioning / conflict resolution happen? | `Table.ts → _writeUpdate` (`#section: write-path-internals`) |
|
|
85
|
+
| How does `search()` choose an index? | `Table.ts → search` (`#section: search-query`) |
|
|
86
|
+
| How are subscriptions replayed? | `Table.ts → subscribe` (`#section: pub-sub`) |
|
|
87
|
+
| How is the response body shaped (select clause)? | `Table.ts → transformEntryForSelect` (`#section: search-query`) |
|
|
88
|
+
| Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`) |
|
|
89
|
+
| How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`) |
|
|
90
|
+
| How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`) |
|
|
91
|
+
| Where is the per-request transaction stored? | `transaction.ts` + `contextStorage` (AsyncLocalStorage) |
|
|
92
|
+
| How does a query opt out of a read snapshot? | Pass `snapshot: false` on the search request (e.g. `get_analytics`). `Table.ts → search` calls `txn.useReadTxn(snapshot === false)`; on RocksDB `DatabaseTransaction.getReadTxn` then builds the read txn with `{ disableSnapshot: true }` so a long scan reads latest without pinning a snapshot. No-op on LMDB (`LMDBTransaction.useReadTxn`). |
|
|
93
|
+
| How does a URL path map to a Resource? | `Resources.ts → getMatch` (exact/prefix fast path) then `matchParamRoute` (parameterised routes); see "Path routing" below |
|
|
94
|
+
| How does HNSW keep the graph connected on delete? | `indexes/HierarchicalNavigableSmallWorld.ts → index()` delete path: zero-degree orphans reindexed via `needsReindexing`; severed multi-node islands detected and reconnected by `repairSeveredNeighbors` (#1712) |
|
|
95
|
+
| How is a filter applied _during_ a vector search? | Predicate-aware traversal (#1241): `search.ts → executeConditions` composes companion AND conditions + a request `vectorFilter` + a record-scoped `allowRead` override into one `(primaryKey) => boolean` (`composeRecordFilter`) and passes it to `HierarchicalNavigableSmallWorld.search(cond, ctx, filter)`. The filter gates result admission at layer 0 only (routing ignores it, ACORN-style); a visit budget (`filterExpansion`) bounds the under-filled/selective case. Very selective _condition_ filters are instead diverted to the exact brute-force path by the query planner's `estimateCountAsSort` ordering. |
|
|
96
|
+
| How is row-level read access control enforced? | Unified `allowRead` (#1422 gap 2 / #1241): an application-OVERRIDDEN `allowRead` (detected via the `isDefaultAllowRead` marker on the framework defaults) is record-scoped — evaluated once per record with `this` = the (frozen) record during query execution, fail-closed on throw, dispatched via the resolved method (never `record.allowRead` lookup — data shadowing). The authorize wrapper (`Resource.ts → authorizeActionOnResource`) defers collection reads on tables (`supportsRowLevelAllowRead`) to this per-record path; single-record `get` keeps the entry check (record loaded, proxied reads work). Records also expose a non-enumerable `allowRead` delegate on the per-table `structPrototype`. |
|
|
93
97
|
|
|
94
98
|
---
|
|
95
99
|
|
|
@@ -128,6 +132,29 @@ Tests: `../unitTests/resources/paramRoutes.test.js` (unit) and `../integrationTe
|
|
|
128
132
|
|
|
129
133
|
---
|
|
130
134
|
|
|
135
|
+
## Typed, discoverable resources (code-first schema + request contract)
|
|
136
|
+
|
|
137
|
+
Design record: the full RFC and its type-level design proofs live in the design PR (**HarperFast/harper#1503**); this section is the retained summary.
|
|
138
|
+
|
|
139
|
+
**The principle.** Harper strips TypeScript at runtime (`--conditions=typestrip`), which erases types and rules out metadata-emitting decorators. So **runtime metadata must be values, and TypeScript types are _derived_ from those values — never the reverse.** Everything here is erasable syntax; the values survive stripping, the types are inferred, nothing can drift. One shared IR — `JsonSchemaFragment` (`jsonSchemaTypes.ts`), produced by `attributeToFragment` — feeds validation, OpenAPI, and MCP, so those surfaces cannot silently disagree.
|
|
140
|
+
|
|
141
|
+
**Code-first tables (`defineTable.ts`).** `defineTable(name, shape, opts)` authors a table in TypeScript and eagerly registers it through the same `table()` factory GraphQL drives — the returned value _is_ the live table class (`Track.get/put/...` work, `new Track()`/`instanceof` hold). Fields come from the `types` vocabulary (getter flags: `string.indexed`, `id.primaryKey`, `date.createdTime`); per-verb shapes are inferred projections discoverable as members (`(typeof Track)['$insert' | '$upsert' | '$patch' | '$query' | '$record']`). Relationships use lazy thunks (`types.relation(() => Album, { from })`) so forward references/cycles resolve at query time; `relationOf`/`hasManyOf` are the escape hatch for a mutual pair whose eager const-inference would otherwise collapse to `any`.
|
|
142
|
+
|
|
143
|
+
**Per-method request contract (`defineResource.ts`).** Two front-ends, same runtime metadata:
|
|
144
|
+
|
|
145
|
+
- `defineResource(contract, impl)` — function form (an object of verb handlers).
|
|
146
|
+
- `Resource.withSchema(contract)` — class form; `extends` it and implement the declared verbs. It pins `static loadAsInstance = false` so instance verbs receive the converged `(target, data)` arg order the types assume (the default dispatch order is `(data, target)` — see `Resource.post/put/patch`).
|
|
147
|
+
|
|
148
|
+
A contract is `{ path, record?, get/post/put/patch/delete: { query?, body?, response? } }`. Handler types are **derived** from it: path params from a template-literal parse of `path`, query/body/response from the schema's inferred type. It is a **subset, not a fork** — a handler gets the SAME `RequestTarget`, structurally narrowed (`target.id: string`, `target.get('expand')` typed by the query schema), and the resource still registers/serves like a plain one. The narrowed types are justified by **runtime enforcement**: each declared verb validates/coerces `query`/`body` before dispatch and throws a structured 400 (`ValidationError`, per-field `{ path, code, message }[]`) — the same bargain `Table.validate` makes for tables.
|
|
149
|
+
|
|
150
|
+
**Vocabulary.** The built-in `t` (`t.string/number/integer/boolean/date/enum/array/object`) and `schemaOf<T>(source?)` both reduce to `JsonSchemaFragment`. A `defineTable` projection slots straight into a contract body/response — `schemaOf<(typeof Track)['$insert']>({ table: Track, projection: 'insert' })` derives the compile-time type from the projection and the runtime fragment from the table's attributes (via `projectTableFragment` → `attributeToFragment`). **Nullability:** non-nullable by default (a bare `t.string` rejects `null`); `.optional` allows absence, `.nullable` allows an explicit `null`; table-derived bodies mirror `Table.validate`'s policy (null rejected only when `nullable === false`).
|
|
151
|
+
|
|
152
|
+
**Downstream surfaces.** `openApi.ts` emits a contract's query params, request body, and response for parameterised routes; `components/mcp/tools/application.ts` drives the tool input/output schema off the contract and binds arbitrary path params + query (`applyContractInputs`), which lifts the generated-verb binding restriction for contract resources. `ValidationError` (`../utility/errors/hdbError.ts`) extends `ClientError` (400) so existing HTTP handling is unchanged; the structured issues ride on `.detail`/`.errors`.
|
|
153
|
+
|
|
154
|
+
Tests: `../unitTests/resources/defineResource.test.js`, `../unitTests/resources/defineTable-registration.test.js`, `../unitTests/resources/openApi-contract.test.js`, `../unitTests/components/mcp/tools/application-contract.test.js`.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
131
158
|
## Conventions
|
|
132
159
|
|
|
133
160
|
- **Never** remove `transactional()` from a static method on `Resource` — it owns transaction context lifetime.
|
package/resources/Resource.ts
CHANGED
|
@@ -18,8 +18,10 @@ import { transaction, contextStorage } from './transaction.ts';
|
|
|
18
18
|
import { parseQuery } from './search.ts';
|
|
19
19
|
import { RequestTarget } from './RequestTarget.ts';
|
|
20
20
|
import { when, promiseNormalize } from '../utility/when.ts';
|
|
21
|
+
import { logger } from '../utility/logging/logger.ts';
|
|
21
22
|
import { registerLiveSubscription } from '../server/liveSubscriptionAuth.ts';
|
|
22
23
|
import type { JsonSchemaFragment } from './jsonSchemaTypes.ts';
|
|
24
|
+
import { makeSchemaClass, type Contract, type SchemaClass } from './defineResource.ts';
|
|
23
25
|
|
|
24
26
|
const EXTENSION_TYPES = {
|
|
25
27
|
json: 'application/json',
|
|
@@ -59,6 +61,26 @@ export class Resource<Record extends object = any> implements ResourceInterface<
|
|
|
59
61
|
static outputSchemas?: { [verb: string]: JsonSchemaFragment };
|
|
60
62
|
static mcp?: { annotations?: { [verb: string]: { [key: string]: unknown } } };
|
|
61
63
|
static hidden?: boolean;
|
|
64
|
+
/** The per-method request contract, when this resource was built via `withSchema`. */
|
|
65
|
+
static requestContract?: Contract;
|
|
66
|
+
/** Per-verb `{ query?, body? }` input schemas derived from the request contract, read by OpenAPI/MCP. */
|
|
67
|
+
static inputSchemas?: { [verb: string]: { query?: JsonSchemaFragment; body?: JsonSchemaFragment } };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Declare a per-method request contract and get back a Resource subclass typed by it. Extend the
|
|
71
|
+
* returned class and implement the declared verbs; handlers receive the SAME
|
|
72
|
+
* `RequestTarget`, structurally narrowed (path params typed, `target.get(param)` typed by the query
|
|
73
|
+
* schema). Each declared verb validates/coerces `query`/`body` before dispatch and returns a
|
|
74
|
+
* structured 400 on failure, which is what justifies the narrowed types. The contract also feeds
|
|
75
|
+
* OpenAPI and MCP from the one declaration.
|
|
76
|
+
*/
|
|
77
|
+
static withSchema<Base extends new (...args: any[]) => any, const C extends Contract>(
|
|
78
|
+
this: Base,
|
|
79
|
+
contract: C
|
|
80
|
+
): SchemaClass<Base, C> {
|
|
81
|
+
return makeSchemaClass(this, contract);
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
constructor(identifier: Id, source: any) {
|
|
63
85
|
this.#id = identifier;
|
|
64
86
|
const context = source?.getContext ? (source.getContext() ?? null) : undefined;
|
|
@@ -283,6 +305,19 @@ export class Resource<Record extends object = any> implements ResourceInterface<
|
|
|
283
305
|
|
|
284
306
|
static query = transactional(
|
|
285
307
|
function (resource: any, query: RequestTarget, _request: Context, data: any) {
|
|
308
|
+
// On the table path, search(target) uses `data` (the request body carrying the conditions) as
|
|
309
|
+
// its sole target, while checkPermission was set on the URL `query`. Thread it across so
|
|
310
|
+
// Table.search sees the flag and enforces row-level allowRead — otherwise a QUERY on a table
|
|
311
|
+
// with an overridden allowRead returns the full unfiltered set (the deferral skips the entry
|
|
312
|
+
// check on the promise that search consumes checkPermission, which it never sees).
|
|
313
|
+
// checkPermission is framework-owned: `data` is the client-controlled QUERY body, so it must
|
|
314
|
+
// always be overwritten here (not just filled when nullish) — otherwise a client could send
|
|
315
|
+
// `checkPermission: false` in the body to disable Table.search's row-level allowRead guard.
|
|
316
|
+
if (resource.constructor.loadAsInstance !== false && data && typeof data === 'object') {
|
|
317
|
+
const checkPermission = (query as any)?.checkPermission;
|
|
318
|
+
if (checkPermission == null) delete (data as any).checkPermission;
|
|
319
|
+
else (data as any).checkPermission = checkPermission;
|
|
320
|
+
}
|
|
286
321
|
return resource.search
|
|
287
322
|
? resource.constructor.loadAsInstance === false
|
|
288
323
|
? resource.search(query, data)
|
|
@@ -491,6 +526,26 @@ export class Resource<Record extends object = any> implements ResourceInterface<
|
|
|
491
526
|
|
|
492
527
|
_assignPackageExport('Resource', Resource);
|
|
493
528
|
|
|
529
|
+
// Warn once per table when an ASYNC allowRead override is used on a row-level-capable table: it is
|
|
530
|
+
// evaluated only at the collection-scope entry check and gets NO per-record/per-event filtering, so
|
|
531
|
+
// the collection-permissive "open, filter per record" pattern would fail open. Row-level filtering
|
|
532
|
+
// requires a synchronous override.
|
|
533
|
+
const asyncAllowReadWarned = new Set<string>();
|
|
534
|
+
function warnAsyncAllowReadOnce(tableName: string | undefined): void {
|
|
535
|
+
const key = tableName ?? '<anonymous>';
|
|
536
|
+
if (asyncAllowReadWarned.has(key)) return;
|
|
537
|
+
asyncAllowReadWarned.add(key);
|
|
538
|
+
logger.warn?.(
|
|
539
|
+
`allowRead override on table "${key}" is async: it is evaluated only at request entry (collection scope) with NO per-record or per-event filtering. Row-level (record-scoped) authorization requires a synchronous allowRead — an async collection-permissive override would return unfiltered results.`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Mark the built-in allowRead so the authorization flow can tell a framework default from an
|
|
544
|
+
// application override. An overridden allowRead on a table is evaluated per RECORD during query
|
|
545
|
+
// execution (#1422 gap 2 / #1241) — with `this` being each record — instead of once at collection
|
|
546
|
+
// entry where `this` has no record to inspect. Table.ts marks its table-level default the same way.
|
|
547
|
+
(Resource.prototype.allowRead as any).isDefaultAllowRead = true;
|
|
548
|
+
|
|
494
549
|
export function snakeCase(camelCase: string) {
|
|
495
550
|
return (
|
|
496
551
|
camelCase[0].toLowerCase() +
|
|
@@ -733,6 +788,43 @@ function transactional(
|
|
|
733
788
|
}
|
|
734
789
|
if (checkPermission) {
|
|
735
790
|
if (loadAsInstance !== false) {
|
|
791
|
+
// Does this table carry an APPLICATION-overridden allowRead (record-scoped, #1422 gap 2)?
|
|
792
|
+
// The framework defaults are marked isDefaultAllowRead; anything else is an app override.
|
|
793
|
+
// Row-level authorization (per-record query traversal, per-event delivery) is SYNC-only.
|
|
794
|
+
// A SYNC application override participates in it; an ASYNC override cannot (traversal
|
|
795
|
+
// and delivery can't await), so it is evaluated ONLY at the collection-scope entry
|
|
796
|
+
// check — a table/connection-level decision, the same as before this feature. It must
|
|
797
|
+
// therefore make a complete decision at collection scope; the collection-permissive
|
|
798
|
+
// "open, filter per record" pattern requires a synchronous override (warned below).
|
|
799
|
+
const overridden =
|
|
800
|
+
options.type === 'read' &&
|
|
801
|
+
(resource.constructor as any)?.supportsRowLevelAllowRead &&
|
|
802
|
+
!(resource.allowRead as any)?.isDefaultAllowRead;
|
|
803
|
+
const rowLevelOverride = overridden && (resource.allowRead as any)?.constructor?.name !== 'AsyncFunction';
|
|
804
|
+
if (rowLevelOverride) {
|
|
805
|
+
// Durable signal that this read was authorization-checked, for the subscription
|
|
806
|
+
// delivery filter (the entry check below clears query.checkPermission before
|
|
807
|
+
// Table.subscribe runs, and an anonymous-but-checked subscription has no user to key on).
|
|
808
|
+
(query as any).rowLevelAuthChecked = true;
|
|
809
|
+
} else if (overridden) {
|
|
810
|
+
warnAsyncAllowReadOnce((resource.constructor as any)?.name);
|
|
811
|
+
}
|
|
812
|
+
// Deferral (per-record enforcement instead of a meaningless collection-scope entry
|
|
813
|
+
// verdict): `get` with a non-null id is a true single-record read (entry check runs with
|
|
814
|
+
// the record loaded) and only defers when isCollection — mirroring the isSearchTarget
|
|
815
|
+
// routing. ALL search/query calls defer: a present id there is a starts_with/prefix SEED
|
|
816
|
+
// (multi-record scan), so an entry verdict would gate — or grant — the whole scan; both
|
|
817
|
+
// invoke instance search() directly, which consumes checkPermission and arms the guard.
|
|
818
|
+
// subscribe/connect are NOT deferred: the entry check is the connection grant, and an
|
|
819
|
+
// override there may be connection-level (e.g. an MQTT topic ACL that decides on
|
|
820
|
+
// context.topic, not record fields, and must run at subscribe time); a record-level
|
|
821
|
+
// override returns permissive at collection scope to open, and Table.subscribe then
|
|
822
|
+
// filters each delivered event per record (#1419).
|
|
823
|
+
if (rowLevelOverride && !isSubscribeAction && (options.method !== 'get' || query.isCollection)) {
|
|
824
|
+
return when(data, (data) => {
|
|
825
|
+
return runAction(data);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
736
828
|
// do permission checks, with allow methods
|
|
737
829
|
let allowed;
|
|
738
830
|
try {
|
|
@@ -802,8 +894,13 @@ function registerLiveSubscriptionForContext(subscription: any, resource: any, qu
|
|
|
802
894
|
// and getCurrentUser() (which reads the resource's context) — evaluate against current state,
|
|
803
895
|
// not the stale user captured at subscribe time.
|
|
804
896
|
if (context) (context as any).user = fresh;
|
|
805
|
-
// Re-run the
|
|
806
|
-
//
|
|
897
|
+
// Re-run the SAME allowRead the subscribe entry check ran, against the fresh user — this
|
|
898
|
+
// mirrors the connection grant exactly. For a record-scoped override (#1419), that override
|
|
899
|
+
// gated the connection at collection scope (typically composing the table/RBAC grant via
|
|
900
|
+
// `super.allowRead`), and its per-record decisions are enforced separately during delivery;
|
|
901
|
+
// re-running it here re-verifies whatever it gated the connection on — a connection-level
|
|
902
|
+
// override (e.g. an MQTT topic ACL) re-checks its topic grant, and a record-scoped override
|
|
903
|
+
// that composes `super` re-checks the RBAC baseline. No per-record evaluation here.
|
|
807
904
|
const reTarget: any = new RequestTarget();
|
|
808
905
|
reTarget.id = capturedId;
|
|
809
906
|
reTarget.isCollection = capturedIsCollection;
|