@harperfast/harper 5.3.0-beta.1 → 5.3.0-beta.2
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/components/DESIGN.md +421 -0
- package/components/mcp/DESIGN.md +109 -0
- package/components/mcp/audit.ts +21 -17
- package/config/DESIGN.md +306 -0
- package/dataLayer/DESIGN.md +179 -0
- package/dataLayer/restoreMarker.ts +92 -25
- package/dist/components/mcp/audit.d.ts +2 -1
- package/dist/components/mcp/audit.js +21 -17
- package/dist/components/mcp/audit.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +21 -8
- package/dist/dataLayer/restoreMarker.js +94 -27
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/RecordEncoder.js +25 -5
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Table.js +48 -2
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +3 -0
- package/dist/resources/analytics/write.js +39 -13
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/crdt.d.ts +10 -0
- package/dist/resources/crdt.js +22 -0
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/databases.js +1 -1
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
- package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
- package/dist/resources/recordLockCoordinator.d.ts +5 -5
- package/dist/resources/recordLockCoordinator.js +48 -16
- package/dist/resources/recordLockCoordinator.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +4 -6
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +59 -23
- package/dist/security/auth.js.map +1 -1
- package/dist/security/deferredAuthentication.d.ts +11 -0
- package/dist/security/deferredAuthentication.js +25 -3
- package/dist/security/deferredAuthentication.js.map +1 -1
- package/dist/security/jsLoader.js +3 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +6 -3
- package/dist/server/REST.js.map +1 -1
- package/dist/server/mqtt.js +5 -1
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
- package/dist/server/serverHelpers/serverUtilities.js +14 -3
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +4 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
- package/dist/utility/logging/harper_logger.js +1 -0
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/index.ts +3 -0
- package/npm-shrinkwrap.json +104 -104
- package/package.json +6 -5
- package/resources/DESIGN.md +568 -3
- package/resources/RecordEncoder.ts +27 -5
- package/resources/Table.ts +49 -3
- package/resources/analytics/DESIGN.md +38 -0
- package/resources/analytics/write.ts +40 -14
- package/resources/crdt.ts +22 -0
- package/resources/databases.ts +1 -1
- package/resources/indexes/DESIGN.md +833 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
- package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
- package/resources/indexes/fullTextNativeBinding.ts +146 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
- package/resources/record-locks.md +1407 -0
- package/resources/recordLockCoordinator.ts +61 -22
- package/resources/scheduler/DESIGN.md +40 -0
- package/resources/transactionBroadcast.ts +4 -4
- package/security/DESIGN.md +175 -0
- package/security/auth.ts +53 -24
- package/security/deferredAuthentication.ts +24 -2
- package/security/jsLoader.ts +3 -0
- package/server/DESIGN.md +264 -0
- package/server/REST.ts +6 -3
- package/server/mqtt.ts +6 -4
- package/server/serverHelpers/serverUtilities.ts +14 -3
- package/server/serverHelpers/uwsServer.ts +4 -1
- package/server/serverHelpers/webSocketCloseReason.ts +25 -0
- package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
- package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
- package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
- package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
- package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
- package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
- package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
- package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
- package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
- package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
- package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
- package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
- package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
- package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
- package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
- package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
- package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
- package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
- package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
- package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
- package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
- package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
- package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
- package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
- package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
- package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/DESIGN.md +55 -0
- package/utility/logging/harper_logger.ts +1 -0
package/server/DESIGN.md
CHANGED
|
@@ -372,3 +372,267 @@ The `@table(cacheControl:)` value is persisted on the primary-key attribute (lik
|
|
|
372
372
|
- New protocol plugins implement the `Server` interface (in `Server.ts`) and register via `onRequest`/`onUpgrade`/`onWebSocket`.
|
|
373
373
|
- Always pass `name` when registering a listener with `before`/`after` — anonymous entries can't be ordered against.
|
|
374
374
|
- Tests live in `../unitTests/server/`.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## The dispatched API operation is carried on async context, never on the request (`server/serverHelpers/operationAuthorizationState.ts`)
|
|
379
|
+
|
|
380
|
+
`verifyPermsAST`'s token-scope check has to be told which top-level API operation the caller
|
|
381
|
+
invoked, because the scope is written in that namespace (`sql`, `export_local`, ...). Two things
|
|
382
|
+
make that awkward:
|
|
383
|
+
|
|
384
|
+
1. On the **direct-SQL** path, the object handed to `checkASTPermissions` _is_ the client's request
|
|
385
|
+
body, and this check is the only gate there (`chooseOperation`'s `sql` branch is mutually
|
|
386
|
+
exclusive with its `verifyPerms` call). Any field read off that object is therefore a way to
|
|
387
|
+
name whichever operation the caller's scope happens to allow and run arbitrary SQL under it.
|
|
388
|
+
`jsonMessage.operation` is safe only because dispatch already routed on that same field, so it
|
|
389
|
+
cannot disagree with the operation running. Never add another.
|
|
390
|
+
2. A **job** re-parses its SQL from the nested `search_operation` in a _different_ async context —
|
|
391
|
+
`executeJob` persists the request and hands off to the job runner, and `jobProcess.ts` re-enters
|
|
392
|
+
from the `hdb_job` record. So a store established around the originating request cannot reach
|
|
393
|
+
it, and the re-parse would be judged as `sql` rather than as the job's own operation.
|
|
394
|
+
|
|
395
|
+
The carrier is therefore established **in the job worker**, by `runWithDispatchedOperation`, from
|
|
396
|
+
the same `request.operation` that `getOperationFunction` just resolved the handler from. That
|
|
397
|
+
identity is the whole basis for trusting it: the value naming the operation and the value selecting
|
|
398
|
+
the code cannot diverge. A new carrier must preserve that property — an added request property, a
|
|
399
|
+
`search_operation` field, or a persisted `parsed_sql_object` would not.
|
|
400
|
+
|
|
401
|
+
This lives in the same `AsyncLocalStorage` as the auth bypass rather than a second store, so
|
|
402
|
+
`processAST` reads the state once. `runWithOperationAuthorizationBypass` **preserves** an existing
|
|
403
|
+
carrier on both branches. That is deliberate and was initially got wrong: its enforced branch is not
|
|
404
|
+
a bypass, so a job handler dispatching a nested _authorized_ operation lands there, and dropping the
|
|
405
|
+
carrier would judge that job's re-parsed SQL as the inner `sql` and refuse it partway through its own
|
|
406
|
+
work. The consequence to know is the other direction — a nested dispatch inside a job is judged
|
|
407
|
+
against the **outer** job's operation for any `evaluateSQL` that does not pass through
|
|
408
|
+
`chooseOperation`. It allocates only when a carrier is present; with none, two shared frozen objects
|
|
409
|
+
serve the common path. All four stores are frozen, so `getOperationAuthorizationState()` cannot hand
|
|
410
|
+
a mutable one to a caller.
|
|
411
|
+
|
|
412
|
+
It has four call sites, and they are not all dispatch wrappers: `server.operation()`
|
|
413
|
+
(`serverUtilities.ts`), the ITC path (`registeredOperations.ts`), the legacy SQL engine
|
|
414
|
+
(`sqlEngine/diff/differential.ts`), and Harper's own `hdb_job` query (`server/jobs/jobs.ts`) — that
|
|
415
|
+
last one **is** reached from the ops-API dispatch, via `search_jobs_by_start_date` →
|
|
416
|
+
`handleGetJobsByStartDate` → `getJobsInDateRange`.
|
|
417
|
+
|
|
418
|
+
Harper's own internal SQL takes the bypass, not the carrier. `getJobsInDateRange` runs a fixed
|
|
419
|
+
`system.hdb_job` query through `evaluateSQL` beneath a handler the caller was already authorized for,
|
|
420
|
+
and `SqlSearchObject` hardcodes `operation: 'sql'` — so the same mismatch applies, but the answer
|
|
421
|
+
differs, and the reason is easy to get backwards. `verifyPermsAST`'s super_user early return is
|
|
422
|
+
`isSuperUser && !isSuSystemOperation`, so a `system` schema is **exempt** from it and the table check
|
|
423
|
+
genuinely runs. A carrier would therefore put Harper's own query through `hasPermissions` on
|
|
424
|
+
`system.hdb_job`, which passes only because `appendSystemTablesToRole` grants `system.*.read` to a
|
|
425
|
+
hydrated super_user — a super_user principal without an appended `permission.system` (an
|
|
426
|
+
impersonation payload, or any path that skips user-cache hydration) would start getting 403s on an
|
|
427
|
+
operation it is entitled to. The bypass also states the actual intent: the statement is Harper's, not
|
|
428
|
+
the caller's. Wrap the individual statement, not the function — a later caller-dependent statement
|
|
429
|
+
must not inherit it.
|
|
430
|
+
|
|
431
|
+
A second body field has to be neutralized for any of this to hold: `evaluateSQL` trusts a supplied
|
|
432
|
+
`parsed_sql_object` verbatim and skips parsing, `chooseOperation` overwrites only the **top-level**
|
|
433
|
+
one, and `dataLayer/export.ts` hands the nested `search_operation` straight to `evaluateSQL`. So a
|
|
434
|
+
body-supplied `search_operation.parsed_sql_object` carrying `permissions_checked: true` would run an
|
|
435
|
+
arbitrary AST with the check skipped. `chooseOperation` deletes it, forcing the worker to re-parse
|
|
436
|
+
from the `sql` string that dispatch authorized — the nested object is never overwritten the way the
|
|
437
|
+
top-level one is, because nothing downstream should read one at all.
|
|
438
|
+
|
|
439
|
+
What is untestable is not the carrier's contract — unit tests cover that by calling
|
|
440
|
+
`runWithDispatchedOperation` directly — but that `jobProcess` is what establishes it. Delete that call
|
|
441
|
+
and those tests stay green. The carrier only changes an outcome through `tokenScopeDenial`, which is
|
|
442
|
+
inert unless the principal carries `tokenOperations`, and that property has exactly one origin: an
|
|
443
|
+
OIDC trust-policy exchange, for which there is no integration harness.
|
|
444
|
+
|
|
445
|
+
Three different mechanisms are easy to conflate here. `tokenOperations` above is the **OIDC token
|
|
446
|
+
operation scope** (#2174). An **inline-role scoped token** (`create_authentication_tokens` with a
|
|
447
|
+
`role` object) is not the same thing and cannot substitute, because `createScopedToken` mints it
|
|
448
|
+
`super_user: false`, so it cannot invoke a `requires_su` operation such as `export_local` at all.
|
|
449
|
+
**Table permissions** are a third, and also cannot substitute — see the system-schema exemption
|
|
450
|
+
above. See #2298.
|
|
451
|
+
|
|
452
|
+
## `universalHeaders` (`http.securityHeaders`): ownership, precedence, and per-thread scope
|
|
453
|
+
|
|
454
|
+
`server/http.ts` exports `universalHeaders: [string, string][]`, applied to responses in the
|
|
455
|
+
Node, Bun, and uWS (`#914`, `HARPER_UWS_HTTP`) request handlers alike. `http.securityHeaders`
|
|
456
|
+
config populates it via `applySecurityHeaders()`, called from `handleApplication()` on load and
|
|
457
|
+
on `scope.options.on('change', ...)`. Three invariants to preserve:
|
|
458
|
+
|
|
459
|
+
- **Ownership tracking.** Other components may push entries onto the same shared array, so a
|
|
460
|
+
hot-reload can't clear-and-rebuild it. `applySecurityHeaders` tracks the exact `[name, value]`
|
|
461
|
+
tuples it previously pushed in a module-level `ownedSecurityHeaders` array and splices only
|
|
462
|
+
those out (by reference, via `indexOf`) before re-adding the new set. Any future feature that
|
|
463
|
+
pushes into `universalHeaders` from a hot-reloadable source should follow the same "track what
|
|
464
|
+
I added, only remove what I added" pattern.
|
|
465
|
+
- **Root scope owns the config.** `'http'` is a `TRUSTED_RESOURCE_PLUGINS` key, so an application
|
|
466
|
+
`config.yaml` with an `http:` block re-invokes `handleApplication`. A module-level guard makes
|
|
467
|
+
only the _first_ invocation (the root config, which loads before applications) own
|
|
468
|
+
`applySecurityHeaders` and its change listener; later invocations still refresh `httpOptions`
|
|
469
|
+
but cannot wipe root-configured headers.
|
|
470
|
+
- **App wins on conflicts.** Universal headers are _defaults_: `applyUniversalHeaders()` (a shared
|
|
471
|
+
helper used by all three transports) only sets a header when `has(name)` is false, and the
|
|
472
|
+
direct-to-`nodeResponse` paths (handlesHeaders, error) check `hasHeader` first. A route that sets
|
|
473
|
+
`X-Frame-Options: DENY` is never loosened by a configured `SAMEORIGIN`. Response paths covered:
|
|
474
|
+
normal writeHead, `handlesHeaders` streams (e.g. the static component's `send()`, which writes
|
|
475
|
+
its own headers directly — universal headers are pre-set on `nodeResponse` / the Bun
|
|
476
|
+
`responseHeaders` shim so the stream can still override its own names), the thrown-error path,
|
|
477
|
+
and the `status === -1` cascade — on Node via the Fastify `'unhandled'` event bridge, on Bun/uWS
|
|
478
|
+
via `injectToFastify` (or the bare-404 fallback when no Fastify instance is registered for the
|
|
479
|
+
port). Each `status === -1` branch builds a **fresh** `Headers` object from the fallback
|
|
480
|
+
response rather than reusing the request's original `headers`, so `applyUniversalHeaders()` must
|
|
481
|
+
be called again on whichever object actually gets returned — applying it only once, before the
|
|
482
|
+
`status === -1` branch, is a trap that silently drops universal headers on every unhandled/404
|
|
483
|
+
response. CI first caught this on the uWS shard (the integration suite's only unauthenticated
|
|
484
|
+
404 case landed there); the same bug existed unnoticed on Bun's parallel `status === -1`
|
|
485
|
+
branches (`getBunHTTPServer`'s bare-404 return and `bunDelegateToNodeServer`'s two `Response`s)
|
|
486
|
+
and is fixed alongside it in `harper-1568-fix2`.
|
|
487
|
+
|
|
488
|
+
**Why the operations API doesn't get these headers in normal mode**: ops requests _do_ flow
|
|
489
|
+
through the Harper-native `requestHandler` (`httpServer()` calls `getServer()` for every
|
|
490
|
+
registration, including Fastify's non-function listener) and cascade to Fastify via the
|
|
491
|
+
`status === -1` branch, which copies `response.headers` onto `nodeResponse`. But the ops API runs
|
|
492
|
+
on the **main thread**, and the main thread loads components with `resources.isWorker = false`
|
|
493
|
+
(`server/loadRootComponents.js`), so the componentLoader's `resources.isWorker &&
|
|
494
|
+
extensionModule.handleApplication` gate (`components/componentLoader.ts`) means http's
|
|
495
|
+
`handleApplication` never runs there — the main thread's `universalHeaders` array stays empty.
|
|
496
|
+
`universalHeaders` is per-thread module state, populated only where the http component loads.
|
|
497
|
+
Corollary: with `threads: 0` the ops API shares the worker where `handleApplication` _did_ run,
|
|
498
|
+
so ops responses **will** carry the headers there (benign).
|
|
499
|
+
|
|
500
|
+
## Under Bun, the main HTTP port is served by `node:http`, not `Bun.serve`
|
|
501
|
+
|
|
502
|
+
Worth knowing before debugging anything Bun-specific on the HTTP path: `getBunHTTPServer()` builds
|
|
503
|
+
the `Bun.serve()` fetch config, but `onWebSocket()` calls `getHTTPServer()` unconditionally — it has
|
|
504
|
+
a uWS branch and no Bun branch, because Bun native WebSockets are unimplemented (nothing ever sets
|
|
505
|
+
`config.websocket`, so WS relies on the Node `ws` server attached to an `http.Server`). MQTT's
|
|
506
|
+
`handleApplication` registers WS on the default port before REST's `httpServer()` call for that same
|
|
507
|
+
port, so `httpServers[port]` is already a Node server by then and `getBunHTTPServer` early-returns
|
|
508
|
+
without registering a serve config. The port is bound by `registerServer()`'s Node server via
|
|
509
|
+
`listenOnPortsBun`'s trailing "non-HTTP servers" loop, and the fetch handler is never invoked for it
|
|
510
|
+
(only the exclusive operations port reaches `Bun.serve`). Consequence: on Bun the `Request`/`Response`
|
|
511
|
+
fetch path is dead code for the main port, and its divergences show up as `node:http`-emulation
|
|
512
|
+
divergences instead.
|
|
513
|
+
|
|
514
|
+
One such divergence, `#2210`: Bun's `node:http` never derives keep-alive from the request. For a
|
|
515
|
+
`Connection: close` request `shouldKeepAlive` stays `true`, and neither a `Connection: close` response
|
|
516
|
+
header nor `response.socket.end()` closes the connection — a **stream-ended** response (an async
|
|
517
|
+
source ended through `pipeline()`; a direct `response.end()` is fine) delivers its full body and
|
|
518
|
+
terminal chunk, then holds the connection until Bun's own idle timeout — a chunked-aware client
|
|
519
|
+
completes the message and can walk away, but the un-honored close still violates RFC 9112 §9.6 and
|
|
520
|
+
strands the socket; a raw client waiting on the FIN (and the HTTP/1.0 case below, which has no
|
|
521
|
+
terminal chunk to stop at) hangs outright. An HTTP/1.0 client hangs the same way
|
|
522
|
+
without asking to close at all, since 1.0 persistence needs both an explicit `keep-alive` and a length
|
|
523
|
+
to read to — so a 1.0 response that got no `Content-Length` is close-delimited, the same line Node
|
|
524
|
+
draws (Node closes it at ~7ms; Bun never does). An explicit `close` token wins over `keep-alive` on
|
|
525
|
+
both versions. A 1.0 `keep-alive` request whose response _did_ get a
|
|
526
|
+
`Content-Length` (`body.size` on a blob, `server/http.ts:698-709`) is left open, which is again what
|
|
527
|
+
Node does and what Bun then handles correctly.
|
|
528
|
+
|
|
529
|
+
`pipeBodyToResponse()` therefore ends `request.socket` itself for those shapes
|
|
530
|
+
(`endConnectionIfClientExpectsClose`, `isBun`-gated, HTTP/1 only, clean path only — the error path
|
|
531
|
+
already closes because `pipeline()` destroys the response with the stream error). Ending the
|
|
532
|
+
_request's_ socket is the only remedy that works after a clean stream end on Bun: a `Connection:
|
|
533
|
+
close` response header, `response.socket.end()` and `response.destroy()` were all measured as no-ops
|
|
534
|
+
there. `socket.end()` is graceful, so it does not truncate — 8 MB over plain TCP and 6 MB over TLS to
|
|
535
|
+
a deliberately slow reader each arrive whole. The
|
|
536
|
+
`Content-Length` check reads `response.hasHeader()`, which Bun populates from the `writeHead(status,
|
|
537
|
+
headers)` fast path this file uses (Node does not, but the branch is Bun-only). A
|
|
538
|
+
keep-alive arm pins the other direction (such a client keeps its connection and reuses it); the two
|
|
539
|
+
HTTP/1.0 arms are Node/Bun-only, because uWS does not route an HTTP/1.0 request to the resource at
|
|
540
|
+
all.
|
|
541
|
+
|
|
542
|
+
## Per-worker UDS mirrors are separate server instances — port-keyed wiring does not reach them (`server/http.ts`)
|
|
543
|
+
|
|
544
|
+
With `tls.unixDomainSockets: true`, every secure port gets a per-worker cleartext mirror
|
|
545
|
+
(`<worker>-<port>.sock`) so a fronting proxy (symphony) can terminate TLS and route to a specific
|
|
546
|
+
worker. The mirror is a **separate** `http.Server` instance registered in `SERVERS[udsPath]` — it is
|
|
547
|
+
_not_ `httpServers[port]` — so anything wired by port key (upgrade listeners, uWS `wsHandler`,
|
|
548
|
+
mTLS flags, socket options) must be explicitly propagated to it. `getHTTPServer()` exposes the
|
|
549
|
+
mirror as `server.udsMirror` (Node) / `server.udsMirrorUwsConfig` (HARPER_UWS_UDS) for exactly this;
|
|
550
|
+
`onWebSocket()` uses those to attach the `'upgrade'` dispatch and uWS `wsHandler`. Two lessons paid
|
|
551
|
+
for in production (WS handshakes died with a zero-byte close on the mirrors while SSE worked):
|
|
552
|
+
|
|
553
|
+
- A Node HTTP server with **no** `'upgrade'` listener destroys upgrade sockets with no response and
|
|
554
|
+
no log — a silent per-server default that makes a missing listener look like a network problem.
|
|
555
|
+
- `enableProxyProtocol()`'s data interception must hand the socket **back to the original
|
|
556
|
+
listeners** once the PROXY header decision is made (it re-attaches them and removes its wrapper).
|
|
557
|
+
A permanent wrapper breaks protocol handoffs: Node's upgrade path removes its parser's `'data'`
|
|
558
|
+
listener _by reference_ before ws takes over, so a lingering wrapper keeps feeding the freed HTTP
|
|
559
|
+
parser — which the parser pool can re-issue to another connection, injecting one connection's
|
|
560
|
+
WS frames into another's request stream (`Parse Error: Data after 'Connection: close'`).
|
|
561
|
+
|
|
562
|
+
The h2c mirror (`HARPER_H2C_UDS`) is exempt: HTTP/1.1 `Upgrade` doesn't exist in h2, and the
|
|
563
|
+
fronting proxy routes WS to the h1 mirror by ALPN.
|
|
564
|
+
|
|
565
|
+
Known limitation on uWS-served transports (`HARPER_UWS_HTTP` ports, `HARPER_UWS_UDS` mirrors):
|
|
566
|
+
uWS accepts WebSocket handshakes natively in `app.ws()`, so `server.upgrade()` middleware never
|
|
567
|
+
runs pre-handshake there (auth is unaffected — it runs in the WS connection chain on both paths,
|
|
568
|
+
matching Node's upgrade-then-authorize order). No core component registers custom upgrade
|
|
569
|
+
middleware; `onUpgrade()`/`installUwsWsHandler()` warn when one is registered for a uWS-served
|
|
570
|
+
port so the gap is visible instead of silent.
|
|
571
|
+
|
|
572
|
+
## A worker that misses an ITC ack gets its OS thread state logged (`server/threads/manageThreads.js`)
|
|
573
|
+
|
|
574
|
+
`broadcastWithAcknowledgement` already times out (30 s) on a worker whose port stays open but never acks, and that shape is almost always a blocked event loop — a native lock, a runaway synchronous call — which nothing inside the worker can report (harper-pro#788: a restarted node's single http worker went byte-silent while main kept serving `cluster_status`, and the app log only said "not acknowledged by worker thread(s) 2"). So each worker posts its Linux thread id (`readlink /proc/thread-self`) to main once at startup, before anything else runs on it, and the timeout branch reads that thread's kernel state from `/proc/self/task/<tid>`: state, `wchan`, the syscall number (the first token only — the rest of that file is argument registers and stack/instruction pointers), CPU ticks, and context-switch counts, plus two cross-platform signals main already has, `worker.performance.eventLoopUtilization()` and the age of the last 1 s resource report. It samples again a second later and logs the deltas: no CPU ticks, no context switches and `event loop active +1000ms` is "parked on a lock"; ticks climbing with state `R` is "spinning". It is deliberately main-thread-only and best-effort: `workers` and the tid live on the main thread's `Worker` objects, every `/proc` field is reported individually (a hardened container may deny `wchan`/`syscall` while `stat` stays readable), a follow-up sample whose `starttime` differs from the first is discarded (the tid may have been recycled), one diagnostic runs per worker with a 30 s cooldown so concurrent timeouts on the same worker don't multiply reads, and nothing here runs when acks arrive on time. It does not name the lock owner; that still needs a native stack from the next occurrence.
|
|
575
|
+
|
|
576
|
+
## `chooseOperation` authorizes the invoked operation against the authenticated principal (`server/serverHelpers/serverUtilities.ts`)
|
|
577
|
+
|
|
578
|
+
`verifyPerms` takes a request-shaped object and reads _both_ halves of the permission question off it: the principal from `hdb_user`, and the tables from `schema`/`database`/`table`/`records`. `chooseOperation` used to hand it `json.search_operation` — a caller-supplied field — which made both halves body-controlled. Fixing one half and not the other is not a fix: with an empty `search_operation` the table map is empty, and `hasPermissions` iterating nothing authorizes everything. Regression cover: `integrationTests/security/choose-operation-authz.test.ts`.
|
|
579
|
+
|
|
580
|
+
Four rules hold this together, and all four are load-bearing:
|
|
581
|
+
|
|
582
|
+
**The principal comes from authentication.** Authentication sets only the _top-level_ `hdb_user`, and `validateRequestBodyProperties` inspects only top-level keys, so a nested `hdb_user` must be overwritten, never backfilled `if (!...)`. All four callers of `chooseOperation` (`serverHandlers`, `serverUtilities.operation`, `registeredOperations` worker forwarding, MCP) set the top-level principal before dispatch, which is why this belongs here rather than only at the HTTP boundary.
|
|
583
|
+
|
|
584
|
+
**`search_operation` is the permission subject only for the operations that consume it.** `dataLayer/export.ts` is its sole consumer (`export_local`, `export_to_s3`); for any other operation the substitution checks the nested tables while the handler runs against the top-level ones, so it is gated on the operation name. It must also be an object naming one of export's supported operations (`search_by_value`/`search_by_hash`/`search_by_conditions`/`sql`) — a primitive, `{}`, or an unsupported operation is a request-time 400, not a wrapped 500 or an asynchronously-failed job.
|
|
585
|
+
|
|
586
|
+
**One check cannot authorize both the outer export and its nested query.** The outer op's own `verifyPerms` returns before any table check — `export_local`/`export_to_s3` are `requires_su`, and a role that lists the operation in `operations` is granted at gate 2 (an explicit listing of an SU-only operation is a deliberate grant). The job worker then runs `search_operation` through `searchByValue`/`searchByHash`/`searchByConditions`, none of which check permissions. So the outer invocation is authorized first, and then the nested search is authorized additively against its _real_ search handler (`getOperationFunction(search_operation)`) and the authenticated principal — otherwise a role granted `export_local` could export a table it holds no grant on. A nested `sql` search takes the SQL branch instead, but the same two-part shape holds: the outer export op runs through `verifyPerms` (so its `requires_su` gate, the `operations` allowlist, and the export token scope all apply, exactly as on the non-SQL path — SQL must not be a way around the requires_su gate), the statement must be a `SELECT` because export is read-only, and `checkASTPermissions` then covers the statement's tables. A direct `sql` call has no outer job op, so there the `operations` allowlist alone is the operation-invocation check.
|
|
587
|
+
|
|
588
|
+
**`parsed_sql_object` is dispatch state, never client input.** The export worker re-reads it off the same caller-supplied nested object (`evaluateSQL`), and it carries `permissions_checked`, so a body-supplied one runs an AST no check ever saw. It is deleted from the nested object at dispatch, and stripped from the top-level object before this dispatch's own parse is assigned. Only the direct-SQL path consumes the top-level `parsed_sql_object`; a job re-parses off `search_operation`, so setting it for a job would be inert. The bypass/`apiOperation` decision is carried on async-context state (`getOperationAuthorizationState`), not on the request body, and `processAST` honors the denial `checkASTPermissions` computes — a `PermissionResponseObject` has no `length`, so the guard tests the object itself rather than `.length` (which always refused nothing).
|
|
589
|
+
|
|
590
|
+
The SQL and job paths are additive rather than exclusive: `verifyPermsAST` validates only the statement's tables and attributes, never the `operations` allowlist or `requires_su`, and a table-free statement gives it nothing to validate — so the allowlist check and the AST check both run for a SQL-carrying request, and the nested-search check runs alongside the outer export check for a job.
|
|
591
|
+
|
|
592
|
+
## `withNodeAdapter()`'s response is the body `PassThrough` it resolves with (`server/serverHelpers/NodeAdapterResponse.ts`)
|
|
593
|
+
|
|
594
|
+
`Request.withNodeAdapter(handler)` gives third-party Node middleware an `IncomingMessage`/`ServerResponse` pair and resolves `{ status, headers, body }` once headers are committed. The response is `NodeAdapterResponse extends PassThrough`, and that same stream is the resolved `body`: `write()`'s return value, `'drain'`, `'finish'`, `'close'`, `writableEnded`/`writableFinished` and destroy propagation are Node's own rather than events forwarded from a second stream, which is what `Readable.pipe`, `compression`'s buffered `res.on('drain')` and Next.js's response writer depend on past the high-water mark (#2527). Invariants that middleware exercises and the unit test `unitTests/server/serverHelpers/nodeAdapterMiddleware.test.js` pins against the real `compression` (1.8 and the 1.7.4 Next.js vendors), `send`, `on-finished` and `on-headers`:
|
|
595
|
+
|
|
596
|
+
- **Headers commit exactly once, through `this.writeHead`.** `write()`, `end()`, `flushHeaders()` and `_implicitHeader()` all reach `this.writeHead(this.statusCode)` by property lookup, so a `writeHead` that `on-headers` replaced on the instance runs its listeners (the ones that set `Content-Encoding` and remove `Content-Length`) before the promise resolves. After commit, `setHeader`/`appendHeader`/`removeHeader` and a second `writeHead` throw `ERR_HTTP_HEADERS_SENT` as Node's do; `_header` (which `compression` ≤ 1.7 tests instead of `headersSent`) and `finished` (which `on-finished` tests) derive from that state.
|
|
597
|
+
- **The adapter owns the `'error'` listener.** A `destroy(err)` right after `writeHead()` emits before the awaiting caller can attach one; the error stays in the stream's `errored` state for `pipeline()`, `finished()` or async iteration. Client disconnect (`Request.signal`) destroys the response without an error after headers (a plain premature close, which `pipeBodyToResponse` treats as routine) and rejects the promise with the abort reason before them; a handler that throws or rejects before ending the response destroys it, and one that fails after `end()` is logged at warn.
|
|
598
|
+
- **Header names are case-insensitive on removal too.** `Headers.delete` lowercases like `set`/`get`/`has`; the inherited `Map.delete` silently left `send`'s `Content-Length` on a gzip body (truncated transfers).
|
|
599
|
+
- **Express is not a target.** `express`'s `app.handle()` replaces the response's prototype with one rooted at `http.ServerResponse.prototype`, which no Writable-derived response survives, and would do the same to the request `Proxy`'s target, Harper's real `IncomingMessage`. Middleware that duck-types the response (Next.js, `compression`, `send`, `serve-static`, `finalhandler`, h3, fastify) is the supported surface.
|
|
600
|
+
|
|
601
|
+
## `manageThreads` has two different `workerCount`s (`server/threads/manageThreads.js`)
|
|
602
|
+
|
|
603
|
+
The module-global `let workerCount` and the per-worker `workerData.workerCount` share a name and
|
|
604
|
+
nothing else. `getWorkerCount()` (and therefore the `server.workerCount` a component reads) resolves
|
|
605
|
+
only `workerData.workerCount`, frozen at spawn — it never reads the global; on the main thread it
|
|
606
|
+
answers `isMainWorker ? 1 : undefined`. The global's one and only reader is `restartWorkers`' default
|
|
607
|
+
`maxWorkersDown = Math.max(Math.floor(workerCount / 8), 1)`, so it is the _serving topology_ the
|
|
608
|
+
rolling-restart throttle is sized from, nothing more.
|
|
609
|
+
|
|
610
|
+
That makes the global writable only by a start that declares the topology. It used to be assigned
|
|
611
|
+
unconditionally from `options.threadCount` inside the `workerData` literal, so every job worker — which
|
|
612
|
+
passes no `threadCount` — set it to `undefined`, and the next rolling restart computed `NaN` (harper#2491).
|
|
613
|
+
`NaN` defeats the guard below it (`NaN < 1` is false) _and_ every throttle comparison, so the restart
|
|
614
|
+
took the whole pool down at once. A string does the same thing for the same reason. So the fix is the conditional write plus a consumer guard that clamps anything not a usable number. `Infinity` is exempt: it is the deliberate "all at once" sentinel `shutdownWorkers` passes, and `shutdownWorkersNow` depends on it to mark every worker synchronously before the first await. A literal `0` is also left alone, because it reads as a ratio rather than as garbage — it still reaches the ratio branch and stops the restart after one worker (harper#2601).
|
|
615
|
+
|
|
616
|
+
`workerData.workerCount` must stay exactly what it is for each start, `undefined` for job workers
|
|
617
|
+
included: an earlier attempt to give job workers the serving count instead broke the Windows
|
|
618
|
+
integration shard with ECONNREFUSED across the job tests. A job worker that believes it is part of the
|
|
619
|
+
pool behaves differently.
|
|
620
|
+
|
|
621
|
+
## A WebSocket close reason must be bounded to 123 bytes (`server/serverHelpers/webSocketCloseReason.ts`)
|
|
622
|
+
|
|
623
|
+
`ws` throws a `RangeError` when a close reason exceeds 123 bytes — the control-frame payload minus the
|
|
624
|
+
status code — and every close site Harper has reaches it from a rejection handler, where that throw
|
|
625
|
+
surfaces as an unhandled rejection rather than a failed close. Two of the reasons are outside Harper's
|
|
626
|
+
control: a `server.getUser` override's rejection text, and `request.pathname` in REST's no-resource
|
|
627
|
+
close. So every `ws.close()` carrying a dynamic reason goes through `toCloseReason()`, which truncates
|
|
628
|
+
on a code-point boundary (harper#2703).
|
|
629
|
+
|
|
630
|
+
The `ClassName: message` in a close reason is deliberate and not something to sanitize: the class name is
|
|
631
|
+
Harper's error code, and `errorToString` is the correct renderer for client-visible error text — see
|
|
632
|
+
AGENTS.md, "An error's class name is its error code". Only an internal fault's _message_ is replaced, by
|
|
633
|
+
`AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL`. The three terminal HTTP handlers (Node and Bun in
|
|
634
|
+
`server/http.ts`, uWS in `server/serverHelpers/uwsServer.ts`) must agree on that rendering; uWS rendered
|
|
635
|
+
the bare message until harper#2703, so the same error carried an error code on two runtimes and not the third.
|
|
636
|
+
|
|
637
|
+
REST settles a credential rejection _before_ its route lookup, so a rejected client gets the unauthorized
|
|
638
|
+
close rather than `1011 No resource was found` — which would otherwise disclose whether the resource exists.
|
package/server/REST.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
assertNoDeferredCredentialRejection,
|
|
26
26
|
settleDeferredCredentialRejection,
|
|
27
27
|
} from '../security/deferredAuthentication.ts';
|
|
28
|
+
import { toCloseReason } from './serverHelpers/webSocketCloseReason.ts';
|
|
28
29
|
|
|
29
30
|
import { Request } from '../server/serverHelpers/Request.ts';
|
|
30
31
|
import { RequestTarget } from '../resources/RequestTarget';
|
|
@@ -585,14 +586,16 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
|
|
|
585
586
|
});
|
|
586
587
|
try {
|
|
587
588
|
await chainCompletion;
|
|
589
|
+
// before the route lookup: the same credential is a 401 over HTTP, so a rejected client
|
|
590
|
+
// must not learn from the close code whether the resource exists
|
|
591
|
+
assertNoDeferredCredentialRejection(request);
|
|
588
592
|
const url = request.url.slice(1);
|
|
589
593
|
const entry = resources.getMatch(url, 'ws');
|
|
590
594
|
recordActionBinary(Boolean(entry), 'connection', 'ws', 'connect');
|
|
591
595
|
if (!entry) {
|
|
592
596
|
// TODO: Ideally we would like to have a 404 response before upgrading to WebSocket protocol, probably
|
|
593
|
-
return ws.close(1011, `No resource was found to handle ${request.pathname}`);
|
|
597
|
+
return ws.close(1011, toCloseReason(`No resource was found to handle ${request.pathname}`));
|
|
594
598
|
} else {
|
|
595
|
-
assertNoDeferredCredentialRejection(request);
|
|
596
599
|
request.handlerPath = entry.path;
|
|
597
600
|
recordAction(
|
|
598
601
|
(action) => ({
|
|
@@ -632,7 +635,7 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
|
|
|
632
635
|
ws.close(
|
|
633
636
|
HTTP_TO_WEBSOCKET_CLOSE_CODES[error.statusCode] || // try to return a helpful code
|
|
634
637
|
1011, // otherwise generic internal error
|
|
635
|
-
errorToString(error)
|
|
638
|
+
toCloseReason(errorToString(error))
|
|
636
639
|
);
|
|
637
640
|
}
|
|
638
641
|
ws.close();
|
package/server/mqtt.ts
CHANGED
|
@@ -19,8 +19,10 @@ import { forComponent as loggerForComponent } from '../utility/logging/harper_lo
|
|
|
19
19
|
import { EventEmitter } from 'events';
|
|
20
20
|
import { verifyCertificate } from '../security/certificateVerification/index.ts';
|
|
21
21
|
import { registerShutdownDrain } from '../components/shutdownDrain.ts';
|
|
22
|
+
import { toCloseReason } from './serverHelpers/webSocketCloseReason.ts';
|
|
22
23
|
import {
|
|
23
24
|
assertNoDeferredCredentialRejection,
|
|
25
|
+
getAuthenticationRejectedInPlace,
|
|
24
26
|
getDeferredCredentialRejection,
|
|
25
27
|
} from '../security/deferredAuthentication.ts';
|
|
26
28
|
|
|
@@ -86,10 +88,10 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
|
|
|
86
88
|
});
|
|
87
89
|
authenticated.catch((error) => {
|
|
88
90
|
mqttLog.info?.('Closing MQTT WebSocket connection, authentication was rejected', error);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
91
|
+
// read the records rather than the error: both hold a client-safe message, while an
|
|
92
|
+
// arbitrary chain rejection landing here would not
|
|
93
|
+
const rejection = getDeferredCredentialRejection(request) ?? getAuthenticationRejectedInPlace(request);
|
|
94
|
+
ws.close(WEBSOCKET_UNAUTHORIZED_CLOSE_CODE, toCloseReason(rejection?.message ?? 'Unauthorized'));
|
|
93
95
|
});
|
|
94
96
|
const { onMessage, onClose } = onSocket(
|
|
95
97
|
ws,
|
|
@@ -89,9 +89,9 @@ export type OperationFunctionName = ValueOf<typeof terms.OPERATIONS_ENUM>;
|
|
|
89
89
|
* name — still stripped, since this runs ahead of the validation that now rejects it). `value` /
|
|
90
90
|
* `values` carry .env secrets from set_env_value; `value` / `envelope` carry secrets from
|
|
91
91
|
* set_secret. `token` is the login-purpose token (login) and the CI identity token
|
|
92
|
-
* (exchange_oidc_token), and is also stripped defensively — no operation declares a top-level
|
|
93
|
-
* but validation allows unknown keys, so a mistyped `harper deploy setup token=…` must not
|
|
94
|
-
* credential. `refresh_token` is the 30-day credential (refresh_operation_token).
|
|
92
|
+
* (exchange_oidc_token), and is also stripped defensively — no operation declares a top-level
|
|
93
|
+
* `token`, but validation allows unknown keys, so a mistyped `harper deploy setup token=…` must not
|
|
94
|
+
* log a live credential. `refresh_token` is the 30-day credential (refresh_operation_token).
|
|
95
95
|
*
|
|
96
96
|
* Redaction runs *before* the handler, so a rejected request logs a still-spendable credential —
|
|
97
97
|
* which is why a new secret-bearing field belongs here rather than left to the default (harper#1527
|
|
@@ -111,10 +111,21 @@ export const UNLOGGABLE_OPERATION_FIELDS = [
|
|
|
111
111
|
'refresh_token',
|
|
112
112
|
];
|
|
113
113
|
|
|
114
|
+
const UNLOGGABLE_FIELDS_BY_OPERATION = new Map<string, ReadonlySet<string>>([
|
|
115
|
+
['add_ssh_key', new Set(['key'])],
|
|
116
|
+
['update_ssh_key', new Set(['key'])],
|
|
117
|
+
]);
|
|
118
|
+
|
|
114
119
|
/** Callers gate this on log level: it allocates, and the operations log is often off. */
|
|
115
120
|
export function redactForOperationLog(body: Record<string, any>): Record<string, any> {
|
|
116
121
|
const clean = { ...body };
|
|
117
122
|
for (const field of UNLOGGABLE_OPERATION_FIELDS) delete clean[field];
|
|
123
|
+
const operationFields = UNLOGGABLE_FIELDS_BY_OPERATION.get(body.operation);
|
|
124
|
+
if (operationFields) {
|
|
125
|
+
for (const field of Object.keys(clean)) {
|
|
126
|
+
if (operationFields.has(field.toLowerCase())) delete clean[field];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
118
129
|
return clean;
|
|
119
130
|
}
|
|
120
131
|
|
|
@@ -24,6 +24,7 @@ import { UwsRequest, UwsRequestBody } from './Request.ts';
|
|
|
24
24
|
import { Headers } from './Headers.ts';
|
|
25
25
|
import { when } from '../../utility/when.ts';
|
|
26
26
|
import { ClientError } from '../../utility/errors/hdbError.ts';
|
|
27
|
+
import { errorToString } from '../../utility/logging/harper_logger.ts';
|
|
27
28
|
|
|
28
29
|
// uWS has no npm package; it's installed from a GitHub tag and is platform/ABI-specific.
|
|
29
30
|
// Imported lazily so harper builds/loads on platforms without a uWS binary.
|
|
@@ -160,7 +161,9 @@ export async function createUwsServer(options: UwsServerOptions): Promise<{ app:
|
|
|
160
161
|
res.cork(() => {
|
|
161
162
|
res.writeStatus(statusText(status));
|
|
162
163
|
res.writeHeader('content-type', 'text/plain');
|
|
163
|
-
|
|
164
|
+
// the same renderer as the Node and Bun terminal handlers: the class name is the
|
|
165
|
+
// error code a client branches on, and it must not depend on the runtime
|
|
166
|
+
res.end(errorToString(error));
|
|
164
167
|
});
|
|
165
168
|
}
|
|
166
169
|
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A close frame's payload is at most 125 bytes, two of which are the status code, and `ws` throws a
|
|
2
|
+
// RangeError past that (node_modules/ws/lib/sender.js). Both close sites below are reached from a
|
|
3
|
+
// rejection handler, where such a throw would surface as an unhandled rejection rather than a failed
|
|
4
|
+
// close, so a message Harper does not control — a `server.getUser` override's, for instance — must be
|
|
5
|
+
// bounded before it gets there.
|
|
6
|
+
const MAX_CLOSE_REASON_BYTES = 123;
|
|
7
|
+
|
|
8
|
+
/** Bounds `text` to what a close frame accepts, truncating on a code-point boundary. */
|
|
9
|
+
export function toCloseReason(text: string | undefined): string {
|
|
10
|
+
const reason = text ?? '';
|
|
11
|
+
// a UTF-16 code unit never encodes to fewer than one byte, so an over-long string is over the byte
|
|
12
|
+
// limit too — testing that first keeps an unbounded message off byteLength's whole-string scan
|
|
13
|
+
if (reason.length <= MAX_CLOSE_REASON_BYTES && Buffer.byteLength(reason, 'utf8') <= MAX_CLOSE_REASON_BYTES)
|
|
14
|
+
return reason;
|
|
15
|
+
let bytes = 0;
|
|
16
|
+
let end = 0;
|
|
17
|
+
// iterating the string yields whole code points, so a surrogate pair is never split
|
|
18
|
+
for (const character of reason) {
|
|
19
|
+
const size = Buffer.byteLength(character, 'utf8');
|
|
20
|
+
if (bytes + size > MAX_CLOSE_REASON_BYTES) break;
|
|
21
|
+
bytes += size;
|
|
22
|
+
end += character.length;
|
|
23
|
+
}
|
|
24
|
+
return reason.slice(0, end);
|
|
25
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{r as e,t}from"./rolldown-runtime-hePW80VL.js";import{S as n,_ as r,a as i,b as a,c as o,d as s,f as c,g as l,h as u,l as d,m as f,n as p,o as m,p as h,r as g,s as _,t as v,v as y,x as b,y as x}from"./vendor-core-c2JRRJpV.js";import{i as S,t as C}from"./button-BIsUKRZq.js";import{a as ee}from"./vendor-datadog-CLUcJXOo.js";import{r as te}from"./vendor-react-CJV_K1u4.js";import{L as ne,k as re,q as ie,z as ae}from"./vendor-tanstack-DxzraizX.js";import{n as oe}from"./setSessionStorage-B0bf71m4.js";import{It as se}from"./vendor-ui-BUjK0h8a.js";import{t as w}from"./createLucideIcon-CzW9508A.js";import{n as ce,r as le,t as ue}from"./chevron-up-DtKGqDn3.js";import{c as de,f as fe,g as pe,h as me,i as he,l as ge,m as _e,p as ve,r as ye,t as be,u as xe}from"./setComponentFile-
|
|
1
|
+
import{r as e,t}from"./rolldown-runtime-hePW80VL.js";import{S as n,_ as r,a as i,b as a,c as o,d as s,f as c,g as l,h as u,l as d,m as f,n as p,o as m,p as h,r as g,s as _,t as v,v as y,x as b,y as x}from"./vendor-core-c2JRRJpV.js";import{i as S,t as C}from"./button-BIsUKRZq.js";import{a as ee}from"./vendor-datadog-CLUcJXOo.js";import{r as te}from"./vendor-react-CJV_K1u4.js";import{L as ne,k as re,q as ie,z as ae}from"./vendor-tanstack-DxzraizX.js";import{n as oe}from"./setSessionStorage-B0bf71m4.js";import{It as se}from"./vendor-ui-BUjK0h8a.js";import{t as w}from"./createLucideIcon-CzW9508A.js";import{n as ce,r as le,t as ue}from"./chevron-up-DtKGqDn3.js";import{c as de,f as fe,g as pe,h as me,i as he,l as ge,m as _e,p as ve,r as ye,t as be,u as xe}from"./setComponentFile-BZRfMD0N.js";import{t as Se}from"./x-DIzaLEdK.js";import{n as Ce}from"./setLocalStorage-D_kflv4U.js";import{t as we}from"./useLocalStorage-BqMR3D8_.js";import{An as Te,Dr as Ee,En as De,H as Oe,It as ke,Mr as Ae,Mt as je,Nt as Me,Or as Ne,Pt as Pe,Tr as Fe,V as Ie,Z as Le,br as Re,c as ze,cr as Be,d as Ve,f as He,fr as Ue,ft as We,hr as Ge,in as Ke,m as qe,or as Je,ur as Ye,ut as Xe,yr as Ze,yt as Qe}from"./index-Dm0rfkJ7.js";import{t as $e}from"./useEntityRestURL-D7bnYxLw.js";import{n as et,t as tt}from"./FloatingChat-D_mI-rZ7.js";import{n as nt}from"./getAnalytics-GHK8ORfM.js";var rt=w(`between-horizontal-start`,[[`rect`,{width:`13`,height:`7`,x:`8`,y:`3`,rx:`1`,key:`pkso9a`}],[`path`,{d:`m2 9 3 3-3 3`,key:`1agib5`}],[`rect`,{width:`13`,height:`7`,x:`8`,y:`14`,rx:`1`,key:`1q5fc1`}]]),it=w(`book`,[[`path`,{d:`M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20`,key:`k3hazp`}]]),at=w(`chart-area`,[[`path`,{d:`M3 3v16a2 2 0 0 0 2 2h16`,key:`c24i48`}],[`path`,{d:`M7 11.207a.5.5 0 0 1 .146-.353l2-2a.5.5 0 0 1 .708 0l3.292 3.292a.5.5 0 0 0 .708 0l4.292-4.292a.5.5 0 0 1 .854.353V16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1z`,key:`q0gr47`}]]),ot=w(`circle-x`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m15 9-6 6`,key:`1uzhvr`}],[`path`,{d:`m9 9 6 6`,key:`z0biqf`}]]),st=w(`file-pen`,[[`path`,{d:`M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34`,key:`o6klzx`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z`,key:`zhnas1`}]]),ct=w(`logs`,[[`path`,{d:`M3 5h1`,key:`1mv5vm`}],[`path`,{d:`M3 12h1`,key:`lp3yf2`}],[`path`,{d:`M3 19h1`,key:`w6f3n9`}],[`path`,{d:`M8 5h1`,key:`1nxr5w`}],[`path`,{d:`M8 12h1`,key:`1con00`}],[`path`,{d:`M8 19h1`,key:`k7p10e`}],[`path`,{d:`M13 5h8`,key:`a7qcls`}],[`path`,{d:`M13 12h8`,key:`h98zly`}],[`path`,{d:`M13 19h8`,key:`c3s6r1`}]]),lt=w(`message-square-heart`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}],[`path`,{d:`M7.5 9.5c0 .687.265 1.383.697 1.844l3.009 3.264a1.14 1.14 0 0 0 .407.314 1 1 0 0 0 .783-.004 1.14 1.14 0 0 0 .398-.31l3.008-3.264A2.77 2.77 0 0 0 16.5 9.5 2.5 2.5 0 0 0 12 8a2.5 2.5 0 0 0-4.5 1.5`,key:`1faxuh`}]]),ut=w(`send`,[[`path`,{d:`M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z`,key:`1ffxy3`}],[`path`,{d:`m21.854 2.147-10.94 10.939`,key:`12cjpa`}]]),dt=w(`wrench`,[[`path`,{d:`M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z`,key:`1ngwbx`}]]);async function ft(){await S.delete(`/Chat/Messages/`)}var T=e(ee(),1),E=te();function pt({setMessages:e}){let[t,n]=(0,T.useState)(!1),r=(0,T.useCallback)(async()=>{if(!t){n(!0);try{await ft(),e([])}catch(e){console.error(`Failed to clear chat:`,e)}finally{n(!1)}}},[t,e]);return(0,E.jsxs)(`button`,{type:`button`,className:`clear-chat-button gap-1`,onClick:r,disabled:t,title:`Clear chat`,children:[t?(0,E.jsx)(Ze,{className:`animate-spin`,size:18}):(0,E.jsx)(Ye,{size:18}),`Clear`]})}async function mt(){let{data:e}=await S.get(`/Chat/Messages/`);return e}var ht=`vercel.ai.error`,gt=Symbol.for(ht),_t,vt,D=class e extends (vt=Error,_t=gt,vt){constructor({name:e,message:t,cause:n}){super(t),this[_t]=!0,this.name=e,this.cause=n}static isInstance(t){return e.hasMarker(t,ht)}static hasMarker(e,t){let n=Symbol.for(t);return typeof e==`object`&&!!e&&n in e&&typeof e[n]==`boolean`&&e[n]===!0}};function yt(e){return e==null?`unknown error`:typeof e==`string`?e:e instanceof Error?e.toString():JSON.stringify(e)}var bt=`AI_InvalidArgumentError`,xt=`vercel.ai.error.${bt}`,St=Symbol.for(xt),Ct,wt,Tt=class extends (wt=D,Ct=St,wt){constructor({message:e,cause:t,argument:n}){super({name:bt,message:e,cause:t}),this[Ct]=!0,this.argument=n}static isInstance(e){return D.hasMarker(e,xt)}},Et=`AI_JSONParseError`,Dt=`vercel.ai.error.${Et}`,Ot=Symbol.for(Dt),kt,At,jt=class extends (At=D,kt=Ot,At){constructor({text:e,cause:t}){super({name:Et,message:`JSON parsing failed: Text: ${e}.
|
|
2
2
|
Error message: ${yt(t)}`,cause:t}),this[kt]=!0,this.text=e}static isInstance(e){return D.hasMarker(e,Dt)}},Mt=`AI_TypeValidationError`,Nt=`vercel.ai.error.${Mt}`,Pt=Symbol.for(Nt),Ft,It,O=class e extends (It=D,Ft=Pt,It){constructor({value:e,cause:t,context:n}){let r=`Type validation failed`;if(n?.field&&(r+=` for ${n.field}`),n?.entityName||n?.entityId){r+=` (`;let e=[];n.entityName&&e.push(n.entityName),n.entityId&&e.push(`id: "${n.entityId}"`),r+=e.join(`, `),r+=`)`}super({name:Mt,message:`${r}: Value: ${JSON.stringify(e)}.
|
|
3
3
|
Error message: ${yt(t)}`,cause:t}),this[Ft]=!0,this.value=e,this.context=n}static isInstance(e){return D.hasMarker(e,Nt)}static wrap({value:t,cause:n,context:r}){return e.isInstance(n)&&n.value===t&&n.context?.field===r?.field&&n.context?.entityName===r?.entityName&&n.context?.entityId===r?.entityId?n:new e({value:t,cause:n,context:r})}},Lt=class extends Error{constructor(e,t){super(e),this.name=`ParseError`,this.type=t.type,this.field=t.field,this.value=t.value,this.line=t.line}},Rt=10,zt=13,k=32;function Bt(e){}function Vt(e){if(typeof e==`function`)throw TypeError("`config` must be an object, got a function instead. Did you mean `createParser({onEvent: fn})`?");let{onEvent:t=Bt,onError:n=Bt,onRetry:r=Bt,onComment:i,maxBufferSize:a}=e,o=[],s=0,c=!0,l,u=``,d=0,f,p=!1;function m(e){if(p)throw Error("Cannot feed parser: it was terminated after exceeding the configured max buffer size. Call `reset()` to resume parsing.");if(c&&(c=!1,e.charCodeAt(0)===239&&e.charCodeAt(1)===187&&e.charCodeAt(2)===191&&(e=e.slice(3))),o.length===0){let t=g(e);t!==``&&(o.push(t),s=t.length),h();return}if(e.indexOf(`
|
|
4
4
|
`)===-1&&e.indexOf(`\r`)===-1){o.push(e),s+=e.length,h();return}o.push(e);let t=o.join(``);o.length=0,s=0;let n=g(t);n!==``&&(o.push(n),s=n.length),h()}function h(){a!==void 0&&(s+u.length<=a||(p=!0,o.length=0,s=0,l=void 0,u=``,d=0,f=void 0,n(new Lt(`Buffered data exceeded max buffer size of ${a} characters`,{type:`max-buffer-size-exceeded`}))))}function g(e){let n=0;if(e.indexOf(`\r`)===-1){let r=e.indexOf(`
|