@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.
Files changed (126) hide show
  1. package/components/DESIGN.md +421 -0
  2. package/components/mcp/DESIGN.md +109 -0
  3. package/components/mcp/audit.ts +21 -17
  4. package/config/DESIGN.md +306 -0
  5. package/dataLayer/DESIGN.md +179 -0
  6. package/dataLayer/restoreMarker.ts +92 -25
  7. package/dist/components/mcp/audit.d.ts +2 -1
  8. package/dist/components/mcp/audit.js +21 -17
  9. package/dist/components/mcp/audit.js.map +1 -1
  10. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  11. package/dist/dataLayer/restoreMarker.js +94 -27
  12. package/dist/dataLayer/restoreMarker.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +6 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/resources/RecordEncoder.js +25 -5
  17. package/dist/resources/RecordEncoder.js.map +1 -1
  18. package/dist/resources/Table.js +48 -2
  19. package/dist/resources/Table.js.map +1 -1
  20. package/dist/resources/analytics/write.d.ts +3 -0
  21. package/dist/resources/analytics/write.js +39 -13
  22. package/dist/resources/analytics/write.js.map +1 -1
  23. package/dist/resources/crdt.d.ts +10 -0
  24. package/dist/resources/crdt.js +22 -0
  25. package/dist/resources/crdt.js.map +1 -1
  26. package/dist/resources/databases.js +1 -1
  27. package/dist/resources/databases.js.map +1 -1
  28. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
  29. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
  30. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  31. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  32. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  33. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  34. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  35. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  36. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  37. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  38. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  39. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  40. package/dist/resources/recordLockCoordinator.d.ts +5 -5
  41. package/dist/resources/recordLockCoordinator.js +48 -16
  42. package/dist/resources/recordLockCoordinator.js.map +1 -1
  43. package/dist/resources/transactionBroadcast.js +4 -6
  44. package/dist/resources/transactionBroadcast.js.map +1 -1
  45. package/dist/security/auth.js +59 -23
  46. package/dist/security/auth.js.map +1 -1
  47. package/dist/security/deferredAuthentication.d.ts +11 -0
  48. package/dist/security/deferredAuthentication.js +25 -3
  49. package/dist/security/deferredAuthentication.js.map +1 -1
  50. package/dist/security/jsLoader.js +3 -0
  51. package/dist/security/jsLoader.js.map +1 -1
  52. package/dist/server/REST.js +6 -3
  53. package/dist/server/REST.js.map +1 -1
  54. package/dist/server/mqtt.js +5 -1
  55. package/dist/server/mqtt.js.map +1 -1
  56. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  57. package/dist/server/serverHelpers/serverUtilities.js +14 -3
  58. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  59. package/dist/server/serverHelpers/uwsServer.js +4 -1
  60. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  61. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  62. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  63. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  64. package/dist/utility/logging/harper_logger.js +1 -0
  65. package/dist/utility/logging/harper_logger.js.map +1 -1
  66. package/index.ts +3 -0
  67. package/npm-shrinkwrap.json +104 -104
  68. package/package.json +6 -5
  69. package/resources/DESIGN.md +568 -3
  70. package/resources/RecordEncoder.ts +27 -5
  71. package/resources/Table.ts +49 -3
  72. package/resources/analytics/DESIGN.md +38 -0
  73. package/resources/analytics/write.ts +40 -14
  74. package/resources/crdt.ts +22 -0
  75. package/resources/databases.ts +1 -1
  76. package/resources/indexes/DESIGN.md +833 -0
  77. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
  78. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  79. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  80. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  81. package/resources/record-locks.md +1407 -0
  82. package/resources/recordLockCoordinator.ts +61 -22
  83. package/resources/scheduler/DESIGN.md +40 -0
  84. package/resources/transactionBroadcast.ts +4 -4
  85. package/security/DESIGN.md +175 -0
  86. package/security/auth.ts +53 -24
  87. package/security/deferredAuthentication.ts +24 -2
  88. package/security/jsLoader.ts +3 -0
  89. package/server/DESIGN.md +264 -0
  90. package/server/REST.ts +6 -3
  91. package/server/mqtt.ts +6 -4
  92. package/server/serverHelpers/serverUtilities.ts +14 -3
  93. package/server/serverHelpers/uwsServer.ts +4 -1
  94. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  95. package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
  96. package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
  97. package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
  98. package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
  99. package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
  100. package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
  101. package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
  102. package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
  103. package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
  104. package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
  105. package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
  106. package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
  107. package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
  108. package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  109. package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
  110. package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
  111. package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
  112. package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
  113. package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
  114. package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
  115. package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
  116. package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
  117. package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  118. package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
  119. package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
  120. package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  121. package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
  122. package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
  123. package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
  124. package/studio/web/index.html +1 -1
  125. package/utility/DESIGN.md +55 -0
  126. package/utility/logging/harper_logger.ts +1 -0
@@ -239,11 +239,11 @@ export interface DelegationReply {
239
239
  dependencies?: LockDependencySet | null;
240
240
  /**
241
241
  * Denied only. `contended` is the one reason that means another node holds the key, and so the only
242
- * one a timeout may report as 423. `generation` (the two sides hold different home maps),
243
- * `unknown-node` (this node is not named in the map), `quarantine` (the home is inside its §4.3
244
- * restart interval) and `timeout` (the home never answered) all describe something other than
245
- * contention, so each ends as a retryable 503 rather than telling the caller a key nobody holds is
246
- * held.
242
+ * one an exhausted wait may report as 423. `generation` (the two sides hold different home maps),
243
+ * `unknown-node` (this node is not named in the map) and `quarantine` (the home is inside its §4.3
244
+ * restart interval) all describe something other than contention, so each ends as a retryable 503
245
+ * rather than telling the caller a key nobody holds is held. `timeout` is not a home answer at all
246
+ * — it is this node's own deadline ending its probe — and never classifies a wait (DESIGN.md).
247
247
  */
248
248
  reason?: 'contended' | 'generation' | 'unknown-node' | 'capacity' | 'not-home' | 'quarantine' | 'timeout';
249
249
  /** Denied with `generation`, so a stale requester can re-derive the ring without another round trip. */
@@ -251,6 +251,13 @@ export interface DelegationReply {
251
251
  retryAfterMs?: number;
252
252
  }
253
253
 
254
+ /** A completed home reply, bound to the route that produced it so a ring change retires it. */
255
+ interface LastCompletedReply {
256
+ reply: DelegationReply;
257
+ home: string;
258
+ generation: number;
259
+ }
260
+
254
261
  export interface DelegationRequest {
255
262
  key: any;
256
263
  /** The asking node. Established by the transport, never read from an untrusted payload. */
@@ -1157,13 +1164,17 @@ export class LockCoordinator {
1157
1164
  * The amortization is the first branch: a live, un-recalled delegation with enough time left costs
1158
1165
  * zero cluster messages.
1159
1166
  */
1160
- async acquire(key: any, leaseMs: number, waitMs: number): Promise<LockRound> {
1167
+ acquire(key: any, leaseMs: number, waitMs: number): Promise<LockRound> {
1168
+ return this.#acquire(key, leaseMs, waitMs);
1169
+ }
1170
+
1171
+ async #acquire(key: any, leaseMs: number, waitMs: number, observed?: LastCompletedReply): Promise<LockRound> {
1161
1172
  // `Table.lock()` captures a coordinator and only reaches here after the native key lock, which
1162
1173
  // can wait the caller's whole timeout — long enough for a transport swap to close what it
1163
1174
  // captured. Authority moved to the successor rather than away, so run there instead of
1164
1175
  // rejecting a caller that is already holding the key.
1165
1176
  const authority = this.#authority();
1166
- if (authority !== this) return authority.acquire(key, leaseMs, waitMs);
1177
+ if (authority !== this) return authority.#acquire(key, leaseMs, waitMs, observed);
1167
1178
  if (this.#closed) throw new LockUnavailableError('Cluster record lock coordination was closed for this table');
1168
1179
  if (!this.transport.ownsCoordination()) {
1169
1180
  // Off the coordinating thread. A transport that can relay obtains the admission from the
@@ -1180,6 +1191,7 @@ export class LockCoordinator {
1180
1191
  }
1181
1192
  const keyId = this.#keyIdOf(key);
1182
1193
  const deadlineMono = this.#monotonic() + waitMs;
1194
+ let lastCompleted = observed;
1183
1195
 
1184
1196
  acquisition: for (;;) {
1185
1197
  const homeMap = this.transport.homeMap(this.database);
@@ -1360,25 +1372,39 @@ export class LockCoordinator {
1360
1372
  // which the terminal answer below is what handles.
1361
1373
  warnOnce('record lock home disagreed about the ring', { database: this.database, table: this.table });
1362
1374
 
1375
+ if (reply.reason !== 'timeout') lastCompleted = { reply, home, generation: homeMap.generation };
1363
1376
  const remaining = deadlineMono - this.#monotonic();
1364
- if (remaining <= 0) {
1365
- // 423 says "someone else holds this key", so only a denial that actually means that may end
1366
- // as one. Every other reason ran out the clock without the key ever being held, and reporting
1367
- // contention for it sends the caller to retry a condition no timeout can outlast.
1368
- if (reply.reason === 'contended') throw new ClientError('Record is locked and was not released in time', 423);
1369
- throw new LockUnavailableError(
1370
- `Could not establish a cluster record lock on ${this.database}.${this.table} within the wait: the key's home answered ${reply.reason ?? (reply.granted ? 'grants that arrived too late to use' : 'nothing usable')}`
1371
- );
1372
- }
1373
- await delay(Math.min(reply.retryAfterMs ?? 25, remaining)).promise;
1377
+ const retryAfterMs = reply.retryAfterMs ?? 25;
1378
+ // A home this node IS costs nothing to ask again — `#grantLocally` is synchronous, so a release
1379
+ // landing in the backoff is still grantable at the deadline. A remote home is not: its request
1380
+ // would go out with the leftover budget and could only return this node's own `timeout`.
1381
+ const exhausted = home === this.nodeId ? remaining <= 0 : remaining <= retryAfterMs;
1382
+ if (!exhausted) await delay(Math.min(retryAfterMs, remaining)).promise;
1374
1383
  if (this.#closed) {
1375
- // Same swap, landing in the backoff instead. Carry the remaining wait so the deadline the
1376
- // caller asked for is preserved across the hop.
1384
+ // Same swap, landing in the backoff instead. The successor inherits both the remaining wait
1385
+ // and what this one saw, since a successor exhausted on arrival has nothing of its own.
1377
1386
  const successor = this.#authority();
1378
1387
  if (successor === this)
1379
1388
  throw new LockUnavailableError('Cluster record lock coordination was closed for this table');
1380
- return successor.acquire(key, leaseMs, Math.max(0, deadlineMono - this.#monotonic()));
1389
+ return successor.#acquire(key, leaseMs, Math.max(0, deadlineMono - this.#monotonic()), lastCompleted);
1381
1390
  }
1391
+ if (!exhausted) continue acquisition;
1392
+ // Only an observation made under the generation that is current NOW still describes the key —
1393
+ // this pass's reply included, since a generation can be activated while the probe that ended
1394
+ // the wait is still in flight. Hence a fresh read rather than the copy this pass started from.
1395
+ const currentGeneration = this.transport.homeMap(this.database)?.generation;
1396
+ const carried =
1397
+ lastCompleted?.home === home && lastCompleted.generation === currentGeneration
1398
+ ? lastCompleted.reply
1399
+ : undefined;
1400
+ const terminal = carried ?? (homeMap.generation === currentGeneration ? reply : undefined);
1401
+ // Only `contended` may end as 423: it is the one answer that says another node holds the key,
1402
+ // and reporting contention for anything else sends the caller to retry a condition no wait
1403
+ // outlasts. A wait can see `contended` and still end 503 — the rule is what the home said LAST.
1404
+ if (terminal?.reason === 'contended') throw new ClientError('Record is locked and was not released in time', 423);
1405
+ throw new LockUnavailableError(
1406
+ `Could not establish a cluster record lock on ${this.database}.${this.table} within the wait: ${describeExhaustedWait(terminal, currentGeneration !== undefined)}`
1407
+ );
1382
1408
  }
1383
1409
  }
1384
1410
 
@@ -2400,8 +2426,6 @@ export class LockCoordinator {
2400
2426
  requested,
2401
2427
  timeout.promise.then(() => {
2402
2428
  raced = true;
2403
- // `timeout`, not `contended`: the home never answered, so nobody was shown to hold the
2404
- // key. Retried the same way, but a wait that ends here is 503 rather than 423.
2405
2429
  return { granted: false, reason: 'timeout', retryAfterMs: 0 } as DelegationReply;
2406
2430
  }),
2407
2431
  ]);
@@ -2694,6 +2718,21 @@ export class LockCoordinator {
2694
2718
  }
2695
2719
  }
2696
2720
 
2721
+ /**
2722
+ * What ended an exhausted `acquire()`, for the 503 it throws. A `timeout` is not phrased as a home
2723
+ * answer: it is this node's own deadline, and an operator told "the home answered timeout" looks for
2724
+ * a fault on a node that was simply not waited for.
2725
+ */
2726
+ function describeExhaustedWait(terminal: DelegationReply | undefined, hasCurrentMap: boolean): string {
2727
+ if (terminal)
2728
+ return terminal.reason === 'timeout'
2729
+ ? "no reply from the key's home within the wait"
2730
+ : `the key's home answered ${terminal.reason ?? (terminal.granted ? 'grants that arrived too late to use' : 'nothing usable')}`;
2731
+ return hasCurrentMap
2732
+ ? 'the record lock home map changed generation before the wait ended'
2733
+ : 'this node has no current record lock home map';
2734
+ }
2735
+
2697
2736
  /** A sleep whose timer the winner of a race can drop, rather than let it run out its full delay. */
2698
2737
  function delay(ms: number): { promise: Promise<void>; cancel: () => void } {
2699
2738
  let timer: ReturnType<typeof setTimeout>;
@@ -0,0 +1,40 @@
1
+ # resources/scheduler/ — Design notes
2
+
3
+ The built-in scheduler plugin.
4
+
5
+ **Read this when:** touching job leadership, leases or heartbeats.
6
+
7
+ Index of every design note: [DESIGN.md](../../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## Scheduler: cluster-once execution without a consensus primitive (`resources/scheduler/`)
12
+
13
+ The built-in `scheduler` plugin (#951) runs config-declared jobs "exactly once per cluster." The
14
+ non-obvious part is what Harper's substrate does and does not offer for that:
15
+
16
+ - **There is no election, consensus, or cross-node CAS anywhere in harper/harper-pro.** Replication
17
+ converges concurrent writes by record version (LWW). A lease acquired with a plain `put` therefore
18
+ cannot be race-free by construction — two nodes writing the lease both succeed locally and
19
+ converge later. The engine's design accepts this: sticky leadership (a starter defers to a fresh
20
+ heartbeat), a heartbeat takeover check (a leader seeing a fresher foreign heartbeat steps down),
21
+ and documented handler idempotency are the mechanisms that ride out transient dual-leadership.
22
+ Do not "fix" the race with a conditional write — the primitive does not exist.
23
+ - The lease + per-job run state live in the replicating system table `hdb_scheduler_state`
24
+ (`audit: true` because system-table replication requires auditing; name chosen because `hdb_job`
25
+ is taken by the legacy jobs subsystem). On constrained/directional replication topologies the
26
+ lease may not reach every node; that limitation is inherent.
27
+ - The alphabetical node-name tie-break deliberately mirrors replication's deterministic failover
28
+ convention (sorted node names in `subscriptionManager.ts`); the escalation ladder
29
+ (`promotionWaitMs`) exists because a dead alphabetically-first node must not deadlock a
30
+ leaderless cluster (each successive node waits one more `2 × watcher interval` rung).
31
+ - Thread-once vs cluster-once are separate layers: `getWorkerIndex() === 0` gates to one worker per
32
+ node (correct in every threading mode incl. `threads: 0`); the lease gates across nodes.
33
+ `handleApplication` holds a cross-thread load lock with a 30s timeout, so the plugin only
34
+ registers there — election, scheduling, and catch-up run async after.
35
+ - Catch-up fires at most ONE missed occurrence per cron job, and a new job's `firstSeenAt` baseline
36
+ prevents firing immediately on first deploy. Interval jobs are excluded from the sweep — they
37
+ self-correct by anchoring to their persisted last run in `scheduleNextRun`.
38
+ - Timer firing is a deliberately thin `setTimeout` layer so the durable timer service (#754) can
39
+ replace it without touching the config surface, election, or catch-up. Timing thresholds are
40
+ env-overridable (`HARPER_SCHEDULER_*`) so multi-node tests can exercise failover in seconds.
@@ -192,7 +192,7 @@ function notifyFromTransactionData(subscriptions, auditLogIterable?, allowYield
192
192
  let ancestorLevel = 0;
193
193
  do {
194
194
  // we iterate through the key hierarchy, notifying all subscribers for each key,
195
- // so for an id like resource/foo/bar, we notify subscribers for resource/foo/bar, resource/foo/, resource/foo, resource/, and resource
195
+ // so for an id like resource/foo/bar, we notify subscribers for resource/foo/bar, resource/foo/, resource/, and the root (null)
196
196
  // this allows for efficient subscriptions to children ids/topics
197
197
  const keySubscriptions = tableSubscriptions.get(matchingKey);
198
198
  if (keySubscriptions) {
@@ -230,9 +230,9 @@ function notifyFromTransactionData(subscriptions, auditLogIterable?, allowYield
230
230
  if (lastSlash !== matchingKey.length - 1) {
231
231
  ancestorLevel++; // don't increase the ancestor level for this going from resource/ to resource
232
232
  }
233
- if (lastSlash > -1) {
234
- matchingKey = matchingKey.slice(0, lastSlash + 1);
235
- } else matchingKey = null;
233
+ // lastIndexOf clamps a negative fromIndex to 0, so '/' would otherwise yield itself forever
234
+ const parentKey = lastSlash > -1 ? matchingKey.slice(0, lastSlash + 1) : null;
235
+ matchingKey = parentKey === matchingKey ? null : parentKey;
236
236
  } while (true);
237
237
  }
238
238
  } else if (auditRecord.type === 'reload') {
@@ -0,0 +1,175 @@
1
+ # security/ — Design notes
2
+
3
+ Authentication tokens, OIDC exchange and TLS material.
4
+
5
+ **Read this when:** touching `tokenAuthentication.ts`, `impersonation.ts`, `authn/oidc/` or `keys.ts`.
6
+
7
+ Index of every design note: [DESIGN.md](../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## OIDC trusted publishing (`security/authn/oidc/`)
12
+
13
+ `exchange_oidc_token` lets a workload authenticate with no stored Harper credential (#2171): it presents an identity token minted by its runtime, and gets back a one-hour operation token for the user a stored trust policy names. It is in `NO_AUTH_OPERATIONS` because it _is_ the authentication, the same way `create_authentication_tokens` is against a password — the same three wiring points apply (`serverHandlers.js` `NO_AUTH_OPERATIONS`, the `verifyPerms` bypass in `serverUtilities.ts`, and a `permission(false, [])` registration).
14
+
15
+ **The core is issuer-agnostic; everything issuer-specific lives in `providers/`.** That split is the point of the layout, not an accident of it — a new workload-identity issuer should be a profile, not a change to verification, matching, or storage.
16
+
17
+ - `claims.ts` — matching and constraint _shape_ validation. Knows nothing about any issuer.
18
+ - `jwks.ts` — issuer keys. The rate-limit clock for unknown-`kid` refetches lives _outside_ the cache entry: a successful fetch replaces the entry, and a rate limit that resets whenever it fires is not a rate limit. Keeping it separate also means a genuine key rotation is picked up on first use rather than after the window.
19
+ - `identityToken.ts` — signature, issuer, audience, `exp`, and a bounded lifetime. Owns `rejectToken`, shared with the exchange so both halves refuse identically.
20
+ - `tokenExchange.ts` — policy selection, replay, minting, audit. Verification is memoized per audience, so N policies sharing one cost one signature check.
21
+ - `providers/` — `assertPolicyIsSpecific` / `assertAudienceIsSpecific` / `normalizeClaims` / `describePrincipal` / optional `vetoClaims`, resolved by normalized issuer.
22
+
23
+ **An unregistered issuer gets `providers/generic.ts`, which is strict rather than permissive:** the policy must pin `sub`. That is what makes Kubernetes service accounts, GCP service accounts, and SPIFFE SVIDs work with zero provider code — each has a stable canonical subject. GitHub needs its own profile precisely because its `sub` is the one claim you should _not_ pin: it varies by trigger, and its format changed for repositories created after 2026-07-15.
24
+
25
+ Four constraints that look like choices but are not:
26
+
27
+ 1. **Every rejection returns the same message.** The endpoint is unauthenticated; a caller told which check failed can enumerate a policy one claim at a time. Reasons go to the `oidc-trust` logger.
28
+ 2. **A GitHub policy must gate the ref.** `githubActionsProfile.assertPolicyIsSpecific` rejects a policy pinning only repository + workflow, because anyone who can push a branch could then add that workflow to it and mint a token. Stricter than npm's trusted-publishing model, which mitigates the same hole with environment protection instead — and profile-scoped, so it never constrains another issuer.
29
+ 3. **`createOperationToken`, not `createTokens`.** `createTokens` overwrites `hdb_user.refresh_token` as a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this feature removes.
30
+ 4. **The role is the boundary; the per-policy `operations` allowlist only narrows it.** Least privilege is primarily the role of the user the policy names. A policy may _optionally_ carry an `operations` scope, which can only subtract from that role — never add to it. It is deliberately not merged into `permission.operations`: gate 2 in `operation_authorization.ts` treats an explicit listing of an SU-only operation as a deliberate grant, so reusing that field would _widen_ where this must only narrow. The scope is carried as a separate `tokenOperations` claim and intersected ahead of every early return, including the super_user bypass.
31
+
32
+ Its enforcement surface is the operations API and SQL (`verifyPerms` / `verifyPermsAST`) — **not** the application REST/GraphQL resource path, which authorizes through table-level `checkPermission` and does not consult the scope. A scoped token therefore still carries its role's full CRUD there, which is why the role has to be least-privilege on its own; the scope is defense in depth, not a substitute. Closing that gap is a follow-up on the same surface as CORE-3061. Because a second authorization mechanism beside roles is one more place for the two to disagree, whether to keep this at all is an open design question on #2173 rather than a settled constraint.
33
+
34
+ Naming `sql` in a scope grants the SQL interface, not unrestricted DML through it: a write statement additionally requires its matching data operation (`insert`/`update`/`delete`) in scope. That is what keeps `read_only` — which expands to include `sql` — from admitting a DELETE, given that `verifyPermsAST` returns early for a super_user before any table check runs.
35
+
36
+ `hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent tokens keyed on a SHA-256 of the token itself, with `expiresAt` past the token's own expiry. Hashed rather than stored, so the table never holds a credential; keyed on the token's **signed input** (`header.payload`) rather than `jti` because not every issuer emits one (Azure uses `uti`). Not on the whole token string: the signature segment is covered by nothing, and base64url decoding ignores the surplus low bits of its final character, so 16 distinct spellings of an RS256 signature decode to the same bytes, all verify, and all hash differently — one leaked token would buy 16 exchanges. ES\* malleability (`s → n−s`) is a second such vector. The signed input is exactly what the issuer asserted, so every variant collapses to one fingerprint. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway.
37
+
38
+ ## Scoped tokens and synthetic-role identity (`security/tokenAuthentication.ts`, `security/impersonation.ts`)
39
+
40
+ `create_authentication_tokens` with an inline `role` **object** mints a `sub: 'scoped-operation'`
41
+ JWT that embeds its whole (downgraded, deep-validated) permission set; the bearer needs no
42
+ `hdb_user`/`hdb_role` row and the `username` is attribution only. Minting is super_user-gated
43
+ (or trusted internal dispatch via `isOperationAuthorizationBypassed()`); a string `role` keeps its
44
+ legacy meaning (component-defined token, rejected by `validateOperationToken`). Scoped tokens get
45
+ no refresh token, touch no user record, and are therefore **irrevocable until expiry** — expiry is
46
+ the only control, which is why `auth.ts` evicts cached Bearer identities at exact `authExpiresAt`
47
+ rather than waiting for the auth-cache TTL.
48
+
49
+ The attribution `username` must NOT name an existing `hdb_user` (rejected at mint; the default is
50
+ `scoped:<minter>`): code paths that rehydrate a user by name would otherwise substitute the real
51
+ principal's permissions for the token's — or fail-closed on the non-existent name. The three known
52
+ by-name sites are handled, all by the same `_scopedToken` short-circuit: the MQTT last-will replay
53
+ (`DurableSubscriptionsSession.ts` persists the scoped role/marker/expiry on the will and skips
54
+ rehydration — and both the restart-replay and the live abnormal-disconnect paths refuse to publish
55
+ a scoped will past `authExpiresAt`), the live-subscription stale-auth recheck (`Resource.ts`
56
+ `registerLiveSubscriptionForContext` keeps the embedded role as the identity), and the MCP
57
+ `list_changed` session refresh (`components/mcp/listChanged.ts` `refreshSessionUser`). The scoped
58
+ principal also cannot self-mint standing tokens: the passwordless path of `createTokens` rejects an
59
+ `hdb_user._scopedToken` requester. **Any future by-name rehydration must check `_scopedToken`.** A
60
+ user _created after minting_ with a colliding name is therefore inert at every current site; the
61
+ residual is only some _new_ unguarded by-name site — another reason to prefer short expiries.
62
+
63
+ Scope of the `operations` allowlist: it gates the **operations API** (including the `sql` path,
64
+ which never reaches `verifyPerms` and calls `verifyOperationsAllowlist` directly from
65
+ `chooseOperation`) — it does NOT gate the application/REST/GraphQL/MQTT surfaces, which authorize
66
+ on translated table CRUD permissions only. A scoped token intended to be read-only on app
67
+ endpoints must carry restrictive table permissions; `operations: ['read_only']` alone does not
68
+ constrain REST writes if table perms allow them.
69
+
70
+ The invariant to preserve when touching any synthetic (inline/impersonated/scoped) role:
71
+ `permissionsTranslator.getRolePermissions` memoizes translated permissions **by role name** (keyed
72
+ further by `__updatedtime__` + schema). A synthetic role must therefore never carry a constant
73
+ name or a per-request timestamp — two different permission sets would alias one cache slot (a
74
+ same-millisecond `Date.now()` was enough), leaking one principal's translated permissions to
75
+ another. `syntheticRoleName()` derives the name from a hash of the post-downgrade permission
76
+ content with `__updatedtime__: 0`, so identical sets share a slot and distinct sets can't collide;
77
+ `applyImpersonation` re-keys all three impersonation modes the same way (Mode B/C previously wrote
78
+ downgraded copies under the _persisted_ role's name). Synthetic translations live in a separate
79
+ 256-entry LRU (`syntheticRolePermsMap`), not the permanent `rolePermsMap` — so >256 concurrently
80
+ live distinct permission sets degrade to per-request translation (a deliberate cliff; raise the
81
+ constant if a legitimate workload hits it). The `_` name prefix is the discriminator; a persisted
82
+ role named with a leading underscore lands in the LRU too (correct, just evictable). Relatedly,
83
+ the role `operations` allowlist gate in `verifyPerms` must stay **ahead of** the ambient privilege
84
+ early-returns (super_user, structure_user, system-table allowances): persisted roles can't combine
85
+ `super_user` with other permission keys, but inline roles can combine `structure_user` with an
86
+ allowlist, and the gate ordering is what keeps unlisted schema ops unreachable.
87
+
88
+ ## TLS hot-reload: cert vs. private key follow two different propagation paths (`security/keys.ts`)
89
+
90
+ A renewed **certificate** and a renewed **private key** reach a worker's live TLS secure context
91
+ by completely separate routes, and the two must reconverge or HTTPS breaks on that worker.
92
+ Certificates propagate through data: only the main thread watches the cert file (`isMainThread`
93
+ guard in `loadCertificates`) and writes the new PEM into the `system.hdb_certificate` table; every
94
+ worker is subscribed to that table and rebuilds its secure contexts (`updateTLS` inside
95
+ `createTLSSelector`) on the notification. Private keys never touch the table — each worker watches
96
+ its own key file (the private-key `loadAndWatch` has no `isMainThread` guard) and loads the PEM
97
+ straight into its in-thread `privateKeys` map. `getPrivateKeyByName` reads that map first, so an
98
+ already-built secure context has the key bytes baked in (`setCert`/`setKey` at build time); a later
99
+ map update does not touch contexts already built.
100
+
101
+ The hazard when a rotation changes **both**: the cert can win the race to a worker (table write +
102
+ subscription) and trigger `updateTLS` before that worker has reloaded the matching key, producing a
103
+ context that pairs the new cert with the old key — every handshake on it then fails, and nothing
104
+ rebuilds it until the _next_ cert-table change. The fix: a private-key reload (`handlePrivateKeyReload`,
105
+ the single sink for both the chokidar watcher and PR #1394's periodic poll) triggers a debounced
106
+ rebuild of every live selector via the module-level `liveTLSRebuilders` set, so the worker reconverges
107
+ on its own. Subtleties to preserve: the rotation guard (`previous !== undefined && previous !== key`)
108
+ must skip both the initial load and identical-content reloads or watchers thrash; transient one-shot
109
+ selectors (`getReplicationCert`) pass `liveReload=false` so they don't accumulate in the never-pruned
110
+ set; and the cert subscription shares the same debounced `scheduleRebuild` (same 1500ms cadence), so
111
+ its coalescing must stay a superset-safe no-op for the single-swap #586 case. Regression coverage:
112
+ `integrationTests/security/cert-key-reload.test.ts` deterministically pins the cert-before-key ordering
113
+ (it fails by design without the rebuild trigger); `cert-reload.test.ts` guards the cert-only #586 path.
114
+
115
+ **Publication is transactional (#2382).** `updateTLS` builds the entire replacement state —
116
+ hostname→context map, CA map, and default candidate — into pass-local candidates and reconciles the
117
+ live maps in place only after the pass completes (their identity is load-bearing:
118
+ `server.secureContexts` and each context's `availableCAs` alias them). A record that is still in the
119
+ table but fails to build (`ERR_OSSL_X509_KEY_VALUES_MISMATCH` when the table's cert outruns the
120
+ on-disk key, a missing key on this thread) keeps every live entry it owns _and_ its default
121
+ candidacy — a record can be serving as the default with no hostname entries at all — so a transient
122
+ mismatch never downgrades serving below last-good (the pre-fix behavior served the self-signed
123
+ default for days). Retention is trust-aware: a context froze its `ca:` list at build time, so when
124
+ the CA set has changed since, the retained pair is rebuilt against the current trust material —
125
+ new handshakes never see revoked client-CA trust; established sessions and outstanding session
126
+ tickets are unaffected, exactly as on a fresh build (ticket keys are process-wide and never rotate
127
+ on trust changes) — and if that rebuild fails the record's entries
128
+ drop for that pass, except when nothing else is servable: the zero-certificate guard then retains
129
+ the old state (availability outranks the drop in that corner) while the failure keeps retrying. Deleting the record remains the way to drop its contexts; a corrupt authority
130
+ row is a pass failure like any other (reported through the signature throttle, armed for retry) and
131
+ its trust drops until it heals. A failed pass arms a
132
+ self-retry on the shared debounce with a per-signature backoff (1.5s doubling to 5min) and
133
+ signature-throttled logging; external triggers (table subscription, key reload) stay at the plain
134
+ debounce. `loadAndWatch` latches its mtime before the callback for chokidar/poll dedupe, but rolls
135
+ the latch back on a synchronous throw or a rejected callback promise (equality-guarded so a stale
136
+ rejection cannot unlatch a newer reload) — the latch means "last successfully applied", so the
137
+ periodic poll can heal a lost `hdb_certificate` write instead of deduplicating it forever.
138
+
139
+ ## A component-facing export needs BOTH `index.ts` and `getHarperExports` (`security/jsLoader.ts`, `index.ts`)
140
+
141
+ Adding `export { x } from './…'` to `index.ts` publishes `x` on the `harper` **package** but does not
142
+ make `import { x } from 'harper'` work inside an application. A component loaded into a VM compartment
143
+ resolves `harper` to a synthetic module built from `getHarperExports()` — a hand-maintained object
144
+ literal, not a re-export of `index.ts` — so a value added to only one list fails at component load with
145
+ `The requested module 'harper' does not provide an export named 'x'`. No unit test sees that, because
146
+ unit tests `require('#src/…')` directly; only a fixture that imports from `'harper'` does
147
+ (`integrationTests/security/fixtures/deferred-credential-rejection/resources.js`, harper#2703).
148
+
149
+ Export the value from the module that _defines_ it rather than re-exporting it through an intermediate,
150
+ so a component-created value shares that module's private symbols. `markCredentialRejection` depends on
151
+ this: its tag is a module-private `Symbol` that `isCredentialRejection` checks by identity.
152
+
153
+ ## Authentication converts every principal-resolution failure into a decision (`security/auth.ts`)
154
+
155
+ `authentication()` resolves a principal from three sources — an mTLS certificate CN, the `Authorization`
156
+ header, and the `hdb_session` cookie — each through the overridable `server.getUser`. A failure from any
157
+ of them must become a **decision**, never a throw: an internal fault (and anything on the operations API,
158
+ where Harper owns every route) is answered in place, and a tagged credential rejection is deferred so the
159
+ layer owning the route settles it. A throw that escapes instead unwinds the whole middleware chain to
160
+ `server/http.ts`'s terminal handler and renders as a plain-text body, which is what harper#2703 reported.
161
+ `settleAuthFailure()` performs the conversion; `rejectAuthenticationInPlace()` is the only way to answer
162
+ in place, and a new principal-resolving path must route through them.
163
+
164
+ Two consequences that are easy to miss:
165
+
166
+ - **A returned 401 is invisible to a WebSocket or MQTT upgrade.** `server/REST.ts` and `server/mqtt.ts`
167
+ only `await chainCompletion` and discard its resolved value, so an in-place decision has to be recorded
168
+ on the request (`markAuthenticationRejectedInPlace`) for `assertNoDeferredCredentialRejection` — which
169
+ both already call — to fail the upgrade closed. Converting a throw into a returned descriptor without
170
+ that record turns a fail-closed upgrade into one that proceeds with no principal. The certificate
171
+ revocation exit is the case that was missed first.
172
+ - **A deferred rejection outranks a later success.** Route owners call
173
+ `settleDeferredCredentialRejection` _before_ they read `request.user`, so once a credential is deferred,
174
+ resolving a principal from a different credential is a contradiction. Hence a rejected certificate
175
+ identity stops resolution outright instead of falling through to Basic, the session, or the local bypass.
package/security/auth.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  deferCredentialRejection,
19
19
  getDeferredCredentialRejection,
20
20
  isCredentialRejection,
21
+ markAuthenticationRejectedInPlace,
21
22
  } from './deferredAuthentication.ts';
22
23
  import { serializeMessage } from '../server/serverHelpers/contentTypes.ts';
23
24
  import { hdbErrors } from '../utility/errors/hdbError.ts';
@@ -69,6 +70,27 @@ export function bypassAuth() {
69
70
  bypassUser = { username: 'bypass', role: { role: 'super_user', permission: { super_user: true } } };
70
71
  }
71
72
 
73
+ /**
74
+ * Turns a failed principal resolution into a decision, so it never escapes the chain as a throw.
75
+ * Returns a response descriptor for the caller to pass through `applyResponseHeaders`, or
76
+ * `undefined` once the rejection has been deferred for the route owner to settle (#2703).
77
+ */
78
+ function settleAuthFailure(request, error, strategy: string): { status: number; body: any } | undefined {
79
+ const internalFault = !isCredentialRejection(error);
80
+ if (request.isOperationsServer || internalFault) {
81
+ if (internalFault) authLogger.error('Authentication failed internally', errorForLog(error));
82
+ const message = internalFault ? AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL : error.message;
83
+ return rejectAuthenticationInPlace(request, message);
84
+ }
85
+ deferCredentialRejection(request, error, strategy);
86
+ return undefined;
87
+ }
88
+
89
+ function rejectAuthenticationInPlace(request, message: string): { status: number; body: any } {
90
+ markAuthenticationRejectedInPlace(request, 401, message);
91
+ return { status: 401, body: serializeMessage({ error: message }, request) };
92
+ }
93
+
72
94
  // TODO: Make this not return a promise if it can be fulfilled synchronously (from cache)
73
95
  export async function authentication(request, nextHandler) {
74
96
  const headers = request.headers.asObject; // we cheat and use the node headers object since it is a little faster
@@ -156,6 +178,7 @@ export async function authentication(request, nextHandler) {
156
178
  )
157
179
  authEventLog.error?.('Authorization error:', request._nodeRequest.socket.authorizationError);
158
180
 
181
+ let mtlsCredentialDeferred = false;
159
182
  if (request.mtlsConfig && request.authorized && request.peerCertificate.subject) {
160
183
  const verificationResult = await verifyCertificate(request.peerCertificate, request.mtlsConfig);
161
184
  if (!verificationResult.valid) {
@@ -165,10 +188,7 @@ export async function authentication(request, nextHandler) {
165
188
  'for',
166
189
  request.peerCertificate.subject.CN
167
190
  );
168
- return applyResponseHeaders({
169
- status: 401,
170
- body: serializeMessage({ error: 'Certificate revoked or verification failed' }, request),
171
- });
191
+ return applyResponseHeaders(rejectAuthenticationInPlace(request, 'Certificate revoked or verification failed'));
172
192
  }
173
193
 
174
194
  // Alternative behavior: Instead of returning 401 above, we could just not set the user
@@ -183,8 +203,19 @@ export async function authentication(request, nextHandler) {
183
203
  // null means no user is defined from certificate, need regular authentication as well
184
204
  if (username === undefined || username === 'Common Name' || username === 'CN')
185
205
  username = request.peerCertificate.subject.CN;
186
- request.user = await server.getUser(username, null, request);
187
- authAuditLog(username, AUTH_AUDIT_STATUS.SUCCESS, 'mTLS');
206
+ let mtlsUser;
207
+ try {
208
+ mtlsUser = await server.getUser(username, null, request);
209
+ } catch (error) {
210
+ if (LOG_AUTH_FAILED) authAuditLog(username, AUTH_AUDIT_STATUS.FAILURE, 'mTLS');
211
+ const failureResponse = settleAuthFailure(request, error, 'mTLS');
212
+ if (failureResponse) return applyResponseHeaders(failureResponse);
213
+ mtlsCredentialDeferred = true;
214
+ }
215
+ if (!mtlsCredentialDeferred) {
216
+ request.user = mtlsUser;
217
+ authAuditLog(username, AUTH_AUDIT_STATUS.SUCCESS, 'mTLS');
218
+ }
188
219
  } else {
189
220
  debug('HTTPS/WSS mTLS authorized connection (mTLS did not authorize a user)', 'from', request.ip);
190
221
  }
@@ -193,6 +224,9 @@ export async function authentication(request, nextHandler) {
193
224
  let newUser;
194
225
  if (request.user) {
195
226
  // already authenticated
227
+ } else if (mtlsCredentialDeferred) {
228
+ // a principal resolved from another credential here would be contradicted by the deferred
229
+ // certificate rejection downstream, so the certificate's decision stands alone
196
230
  } else if (authorization) {
197
231
  let cachedUser = authorizationCache.get(authorization);
198
232
  // A cached Bearer identity must not outlive its token: expiry is the only revocation
@@ -214,7 +248,7 @@ export async function authentication(request, nextHandler) {
214
248
  const strategy = authorization.slice(0, spaceIndex);
215
249
  const credentials = authorization.slice(spaceIndex + 1);
216
250
  let username, password;
217
- let credentialRejection;
251
+ let credentialDeferred = false;
218
252
  try {
219
253
  switch (strategy) {
220
254
  case 'Basic':
@@ -269,23 +303,12 @@ export async function authentication(request, nextHandler) {
269
303
  }
270
304
  }
271
305
 
272
- const internalFault = !isCredentialRejection(err);
273
- if (request.isOperationsServer || internalFault) {
274
- if (internalFault) authLogger.error('Authentication failed internally', errorForLog(err));
275
- return applyResponseHeaders({
276
- status: 401,
277
- body: serializeMessage(
278
- { error: internalFault ? AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL : err.message },
279
- request
280
- ),
281
- });
282
- }
283
- credentialRejection = err;
306
+ const failureResponse = settleAuthFailure(request, err, strategy);
307
+ if (failureResponse) return applyResponseHeaders(failureResponse);
308
+ credentialDeferred = true;
284
309
  }
285
310
 
286
- if (credentialRejection) {
287
- deferCredentialRejection(request, credentialRejection, strategy);
288
- } else {
311
+ if (!credentialDeferred) {
289
312
  authorizationCache.set(authorization, newUser);
290
313
  if (LOG_AUTH_SUCCESSFUL && newUser != null)
291
314
  authAuditLog(newUser.username, AUTH_AUDIT_STATUS.SUCCESS, strategy);
@@ -299,8 +322,14 @@ export async function authentication(request, nextHandler) {
299
322
 
300
323
  request.user = newUser;
301
324
  } else if (session?.user) {
302
- // or should this be cached in the session?
303
- request.user = await server.getUser(session.user, null, request);
325
+ try {
326
+ // or should this be cached in the session?
327
+ request.user = await server.getUser(session.user, null, request);
328
+ } catch (error) {
329
+ if (LOG_AUTH_FAILED) authAuditLog(session.user, AUTH_AUDIT_STATUS.FAILURE, 'Session');
330
+ const failureResponse = settleAuthFailure(request, error, 'Session');
331
+ if (failureResponse) return applyResponseHeaders(failureResponse);
332
+ }
304
333
  } else if (
305
334
  (AUTHORIZE_LOCAL && bypassUser) || // explicit bypass (test mode); also covers ::ffff:127.x addresses
306
335
  (AUTHORIZE_LOCAL && (request.ip?.includes('127.0.0.') || request.ip == '::1')) ||
@@ -65,7 +65,29 @@ export function settleDeferredCredentialRejection(
65
65
  };
66
66
  }
67
67
 
68
+ const REJECTED_IN_PLACE = Symbol('harper.authenticationRejectedInPlace');
69
+
70
+ /**
71
+ * Records an authentication decision the middleware answered in place, with the client-safe message
72
+ * the HTTP response carries. A WebSocket or MQTT upgrade only awaits the chain and never reads its
73
+ * resolved value, so a returned 401 is invisible to it; without this record such an upgrade would
74
+ * continue with no principal on a fault that failed the equivalent HTTP request closed.
75
+ */
76
+ export function markAuthenticationRejectedInPlace(request: any, status: number, message: string): void {
77
+ if (request?.[REJECTED_IN_PLACE]) return;
78
+ Object.defineProperty(request, REJECTED_IN_PLACE, {
79
+ value: Object.freeze({ status, message }),
80
+ enumerable: false,
81
+ configurable: false,
82
+ writable: false,
83
+ });
84
+ }
85
+
86
+ export function getAuthenticationRejectedInPlace(request: any): { status: number; message: string } | undefined {
87
+ return request?.[REJECTED_IN_PLACE];
88
+ }
89
+
68
90
  export function assertNoDeferredCredentialRejection(request: any): void {
69
- const deferred = getDeferredCredentialRejection(request);
70
- if (deferred) throw new ClientError(deferred.message, deferred.status);
91
+ const rejected = getDeferredCredentialRejection(request) ?? getAuthenticationRejectedInPlace(request);
92
+ if (rejected) throw new ClientError(rejected.message, rejected.status);
71
93
  }
@@ -18,6 +18,7 @@ import * as child_process from 'node:child_process';
18
18
  import { CONFIG_PARAMS, DEFAULT_DATABASE_NAME } from '../utility/hdbTerms.ts';
19
19
 
20
20
  import { contentTypes } from '../server/serverHelpers/contentTypes.ts';
21
+ import { markCredentialRejection, credentialRejectionError } from './credentialRejection.ts';
21
22
  import type {} from 'ses';
22
23
  import {
23
24
  existsSync,
@@ -946,6 +947,8 @@ function getHarperExports(scope: ApplicationScope) {
946
947
  authenticateUser: server.authenticateUser,
947
948
  operation: server.operation,
948
949
  contentTypes,
950
+ markCredentialRejection,
951
+ credentialRejectionError,
949
952
  Attribute: undefined,
950
953
  Config: undefined,
951
954
  ConfigValue: undefined,