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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dist/agent/agent.d.ts +24 -3
  28. package/dist/agent/agent.js +172 -10
  29. package/dist/agent/agent.js.map +1 -1
  30. package/dist/agent/bestPractices.d.ts +24 -0
  31. package/dist/agent/bestPractices.js +60 -0
  32. package/dist/agent/bestPractices.js.map +1 -0
  33. package/dist/agent/mcpTools.d.ts +24 -0
  34. package/dist/agent/mcpTools.js +115 -0
  35. package/dist/agent/mcpTools.js.map +1 -0
  36. package/dist/agent/operations.js +10 -1
  37. package/dist/agent/operations.js.map +1 -1
  38. package/dist/agent/registryTools.d.ts +45 -0
  39. package/dist/agent/registryTools.js +113 -0
  40. package/dist/agent/registryTools.js.map +1 -0
  41. package/dist/agent/session.js +16 -7
  42. package/dist/agent/session.js.map +1 -1
  43. package/dist/agent/tools/fsTools.js +70 -28
  44. package/dist/agent/tools/fsTools.js.map +1 -1
  45. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  46. package/dist/agent/tools/inspectorTool.js +428 -0
  47. package/dist/agent/tools/inspectorTool.js.map +1 -0
  48. package/dist/agent/toolset.d.ts +16 -6
  49. package/dist/agent/toolset.js +17 -7
  50. package/dist/agent/toolset.js.map +1 -1
  51. package/dist/agent/types.d.ts +2 -0
  52. package/dist/components/Application.d.ts +73 -18
  53. package/dist/components/Application.js +475 -64
  54. package/dist/components/Application.js.map +1 -1
  55. package/dist/components/OptionsWatcher.d.ts +1 -1
  56. package/dist/components/OptionsWatcher.js +65 -2
  57. package/dist/components/OptionsWatcher.js.map +1 -1
  58. package/dist/components/Scope.d.ts +8 -6
  59. package/dist/components/Scope.js +22 -6
  60. package/dist/components/Scope.js.map +1 -1
  61. package/dist/components/componentLoader.js +5 -1
  62. package/dist/components/componentLoader.js.map +1 -1
  63. package/dist/components/componentSecrets.d.ts +33 -7
  64. package/dist/components/componentSecrets.js +473 -59
  65. package/dist/components/componentSecrets.js.map +1 -1
  66. package/dist/components/deploymentRecorder.d.ts +2 -2
  67. package/dist/components/deploymentRecorder.js +1 -1
  68. package/dist/components/deploymentRecorder.js.map +1 -1
  69. package/dist/components/gitCredentialHelper.d.ts +1 -0
  70. package/dist/components/gitCredentialHelper.js +113 -0
  71. package/dist/components/gitCredentialHelper.js.map +1 -0
  72. package/dist/components/gitCredentialServer.d.ts +33 -0
  73. package/dist/components/gitCredentialServer.js +236 -0
  74. package/dist/components/gitCredentialServer.js.map +1 -0
  75. package/dist/components/mcp/tools/application.d.ts +19 -0
  76. package/dist/components/mcp/tools/application.js +180 -22
  77. package/dist/components/mcp/tools/application.js.map +1 -1
  78. package/dist/components/mcp/tools/operations.d.ts +13 -0
  79. package/dist/components/mcp/tools/operations.js +1 -0
  80. package/dist/components/mcp/tools/operations.js.map +1 -1
  81. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  82. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  83. package/dist/components/operations.js +23 -21
  84. package/dist/components/operations.js.map +1 -1
  85. package/dist/components/operationsValidation.js +84 -21
  86. package/dist/components/operationsValidation.js.map +1 -1
  87. package/dist/components/secretOperations.d.ts +33 -11
  88. package/dist/components/secretOperations.js +90 -26
  89. package/dist/components/secretOperations.js.map +1 -1
  90. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  91. package/dist/config/harperConfigEnvVars.js +95 -5
  92. package/dist/config/harperConfigEnvVars.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/resources/Resource.d.ts +19 -0
  97. package/dist/resources/Resource.js +93 -2
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/Table.d.ts +9 -0
  100. package/dist/resources/Table.js +256 -50
  101. package/dist/resources/Table.js.map +1 -1
  102. package/dist/resources/analytics/read.js +28 -25
  103. package/dist/resources/analytics/read.js.map +1 -1
  104. package/dist/resources/defineResource.d.ts +180 -0
  105. package/dist/resources/defineResource.js +505 -0
  106. package/dist/resources/defineResource.js.map +1 -0
  107. package/dist/resources/defineTable.d.ts +221 -0
  108. package/dist/resources/defineTable.js +227 -0
  109. package/dist/resources/defineTable.js.map +1 -0
  110. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  111. package/dist/resources/jsonSchemaTypes.js +12 -4
  112. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  113. package/dist/resources/openApi.js +69 -13
  114. package/dist/resources/openApi.js.map +1 -1
  115. package/dist/resources/search.js +6 -8
  116. package/dist/resources/search.js.map +1 -1
  117. package/dist/server/graphqlQuerying.js +4 -2
  118. package/dist/server/graphqlQuerying.js.map +1 -1
  119. package/dist/server/http.d.ts +12 -0
  120. package/dist/server/http.js +35 -15
  121. package/dist/server/http.js.map +1 -1
  122. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  123. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +23 -0
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/utility/errors/hdbError.d.ts +21 -0
  127. package/dist/utility/errors/hdbError.js +23 -1
  128. package/dist/utility/errors/hdbError.js.map +1 -1
  129. package/dist/validation/configValidator.js +22 -6
  130. package/dist/validation/configValidator.js.map +1 -1
  131. package/index.ts +33 -0
  132. package/npm-shrinkwrap.json +9197 -15236
  133. package/package.json +5 -3
  134. package/resources/DESIGN.md +42 -15
  135. package/resources/Resource.ts +99 -2
  136. package/resources/Table.ts +275 -71
  137. package/resources/analytics/read.ts +30 -25
  138. package/resources/defineResource.ts +651 -0
  139. package/resources/defineTable.ts +407 -0
  140. package/resources/jsonSchemaTypes.ts +12 -4
  141. package/resources/openApi.ts +68 -16
  142. package/resources/search.ts +5 -8
  143. package/server/graphqlQuerying.ts +4 -2
  144. package/server/http.ts +40 -16
  145. package/server/serverHelpers/serverUtilities.ts +19 -6
  146. package/server/serverHelpers/uwsServer.ts +24 -0
  147. package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
  148. package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
  149. package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
  150. package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
  151. package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
  152. package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
  153. package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
  154. package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
  155. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
  156. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
  157. package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
  158. package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
  159. package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
  160. package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
  161. package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
  162. package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
  163. package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
  164. package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
  165. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
  166. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
  167. package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
  168. package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
  169. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
  170. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
  171. package/studio/web/index.html +1 -1
  172. package/utility/errors/hdbError.ts +32 -0
  173. package/validation/configValidator.ts +23 -6
@@ -7,6 +7,9 @@ exports.secrets = void 0;
7
7
  exports.materializeGlobalSecrets = materializeGlobalSecrets;
8
8
  exports.processComponentEnv = processComponentEnv;
9
9
  exports.getUnsatisfiedEnv = getUnsatisfiedEnv;
10
+ exports.closeComponentSubscriptions = closeComponentSubscriptions;
11
+ exports.retainComponentSubscriptions = retainComponentSubscriptions;
12
+ exports.releaseComponentSubscriptions = releaseComponentSubscriptions;
10
13
  exports.getSecretsForComponent = getSecretsForComponent;
11
14
  exports.runWithComponentBinding = runWithComponentBinding;
12
15
  exports.resetComponentSecrets = resetComponentSecrets;
@@ -46,8 +49,12 @@ exports.resetComponentSecrets = resetComponentSecrets;
46
49
  * rather than guessing. The recommended idiom is a module-top-level destructure
47
50
  * (`const { MY_KEY } = secrets;`), where binding is exact in every mode.
48
51
  *
49
- * Late custody / changed secrets heal on restart or component reload (each load cycle re-reads the
50
- * store) there is no live re-materialization.
52
+ * Liveness (#1776): a change watcher on the store keeps the SCOPED tier live the per-component
53
+ * accessor (`secrets.NAME`) and `secrets.subscribe(name)` reflect a later set_secret/grant/revoke/delete
54
+ * without a reload. The GLOBAL (`process.env`) tier stays reload-only: a live change never re-mutates
55
+ * `process.env` under already-running code (child processes have inherited it; arbitrary code has cached
56
+ * it), so materialized env values still heal only on restart or component reload. Late custody likewise
57
+ * heals on reload (each load cycle re-reads and re-decrypts the store).
51
58
  */
52
59
  const node_async_hooks_1 = require("node:async_hooks");
53
60
  const databases_ts_1 = require("../resources/databases.js");
@@ -66,7 +73,42 @@ const ownedEnvKeys = new Set();
66
73
  // Per-component registries, rebuilt as each component's env block is processed.
67
74
  const declaredEnvNames = new Map();
68
75
  const unsatisfiedEnv = new Map();
76
+ // Cached LIVE per-component view proxies (stable per component; they resolve values on each read, so
77
+ // they never need invalidating on a secret change — only on reset).
69
78
  const accessorCache = new Map();
79
+ // Components already warned about a secret named `subscribe` shadowing the reserved method.
80
+ const warnedSubscribeShadow = new Set();
81
+ /**
82
+ * Build the in-memory state for one hdb_secret row: normalize grants, read the tier flag, and
83
+ * decrypt the envelope with this node's custody. The single source of truth for turning a stored
84
+ * record into a {grants, processEnv, value?, failure?} — shared by the load-cycle scan
85
+ * (doMaterializeGlobalSecrets) and the live change watcher (dispatchSecretChange), so both paths
86
+ * apply identical decrypt/failure semantics.
87
+ */
88
+ function rowStateFromRecord(record) {
89
+ const state = {
90
+ grants: Array.isArray(record.grants) ? [...new Set(record.grants)] : [],
91
+ processEnv: record.processEnv === true,
92
+ };
93
+ if (typeof record.envelope === 'string') {
94
+ const decryptor = (0, secretDecryptor_ts_1.getSecretDecryptor)();
95
+ if (!decryptor) {
96
+ state.failure = 'no secrets custody is registered on this node';
97
+ }
98
+ else {
99
+ try {
100
+ state.value = decryptor(record.envelope);
101
+ }
102
+ catch (error) {
103
+ state.failure = `decrypt failed: ${error.message}`;
104
+ }
105
+ }
106
+ }
107
+ else {
108
+ state.failure = 'stored row has no envelope';
109
+ }
110
+ return state;
111
+ }
70
112
  /**
71
113
  * Read the hdb_secret store, decrypt what this node's custody allows, materialize the global tier
72
114
  * (`processEnv: true` rows) into the real process.env, and snapshot the scoped tier for the accessor. Runs
@@ -93,51 +135,69 @@ function materializeGlobalSecrets() {
93
135
  }));
94
136
  }
95
137
  let materializeInFlight;
138
+ // Bump on every applied live change, so a full rescan can detect that its stable-snapshot view raced
139
+ // a newer watcher update and retry rather than clobber it (see scanIntoSecretRows).
140
+ let secretEventSeq = 0;
141
+ const SCAN_RETRY_LIMIT = 5;
142
+ /**
143
+ * Rebuild `secretRows` (the live snapshot behind the scoped accessor and subscriptions) from a full
144
+ * table scan, then re-deliver current values to every open stream. `table.search` uses a stable
145
+ * snapshot, so a scan that started before a revoke could otherwise complete afterward and re-authorize
146
+ * the just-revoked secret; to prevent that we retry the scan when a live change was applied while it
147
+ * ran, and the final assignment is synchronous (no await) so no event can interleave between the guard
148
+ * check and the assignment. Does NOT touch process.env — callers that must (re)materialize the global
149
+ * tier do that separately (it stays reload-only).
150
+ */
151
+ async function scanIntoSecretRows(table) {
152
+ let rows;
153
+ let before;
154
+ let attempts = 0;
155
+ do {
156
+ before = secretEventSeq;
157
+ rows = new Map();
158
+ for await (const record of table.search([])) {
159
+ const name = record.name;
160
+ if (typeof name !== 'string' || !name)
161
+ continue;
162
+ rows.set(name, rowStateFromRecord(record));
163
+ }
164
+ } while (before !== secretEventSeq && ++attempts < SCAN_RETRY_LIMIT);
165
+ if (before !== secretEventSeq) {
166
+ harper_logger_ts_1.default.warn('Secrets store changed repeatedly during a rescan; applying the latest scan (heals on the next change)');
167
+ }
168
+ secretRows = rows;
169
+ storeAvailable = true;
170
+ redispatchAllStreams();
171
+ return rows;
172
+ }
96
173
  async function doMaterializeGlobalSecrets() {
97
174
  const table = databases_ts_1.databases.system?.[SECRET_TABLE];
98
175
  if (!table) {
99
176
  storeAvailable = false;
100
177
  secretRows = new Map();
101
178
  accessorCache.clear();
179
+ redispatchAllStreams(); // reflect the store going away to any open streams
102
180
  harper_logger_ts_1.default.debug?.(`Secrets store not initialized (system.${SECRET_TABLE} missing); component env declarations can only be satisfied from process.env`);
103
181
  return;
104
182
  }
105
- const rows = new Map();
106
- const decryptor = (0, secretDecryptor_ts_1.getSecretDecryptor)();
183
+ // Start the watcher BEFORE the scan so a change committed during the scan bumps secretEventSeq and
184
+ // forces a retry (closing the scan→attach gap where a deletion would otherwise be lost). Non-fatal:
185
+ // a subscribe failure leaves the node bootable with reload-only secrets.
107
186
  try {
108
- for await (const row of table.search([])) {
109
- const name = row.name;
110
- if (typeof name !== 'string' || !name)
111
- continue;
112
- const state = {
113
- grants: Array.isArray(row.grants) ? [...new Set(row.grants)] : [],
114
- processEnv: row.processEnv === true,
115
- };
116
- if (typeof row.envelope === 'string') {
117
- if (!decryptor) {
118
- state.failure = 'no secrets custody is registered on this node';
119
- }
120
- else {
121
- try {
122
- state.value = decryptor(row.envelope);
123
- }
124
- catch (error) {
125
- state.failure = `decrypt failed: ${error.message}`;
126
- }
127
- }
128
- }
129
- else {
130
- state.failure = 'stored row has no envelope';
131
- }
132
- rows.set(name, state);
133
- }
187
+ await ensureSecretWatcher(table);
188
+ }
189
+ catch (error) {
190
+ harper_logger_ts_1.default.warn(`Could not start the live secrets change watcher (secrets will be reload-only): ${error.message}`);
191
+ }
192
+ let rows;
193
+ try {
194
+ rows = await scanIntoSecretRows(table);
134
195
  }
135
196
  catch (error) {
136
197
  // Leave the previous snapshot (and process.env) untouched rather than acting on a partial read.
137
198
  harper_logger_ts_1.default.error(`Failed to read the secrets store (system.${SECRET_TABLE}): ${error.message}`);
138
199
  return;
139
200
  }
140
- storeAvailable = true;
141
201
  for (const [name, state] of rows) {
142
202
  if (!state.processEnv) {
143
203
  // Scoped tier (or an inert un-granted row): never in process.env. If a previous cycle
@@ -169,8 +229,6 @@ async function doMaterializeGlobalSecrets() {
169
229
  ownedEnvKeys.delete(name);
170
230
  }
171
231
  }
172
- secretRows = rows;
173
- accessorCache.clear();
174
232
  }
175
233
  function parseDeclaration(componentName, name, spec) {
176
234
  if (spec === null)
@@ -293,35 +351,376 @@ function processComponentEnv(componentName, envConfig) {
293
351
  function getUnsatisfiedEnv(componentName) {
294
352
  return unsatisfiedEnv.get(componentName) ?? [];
295
353
  }
354
+ class SecretChangeStream {
355
+ // At most one undelivered value: this is a current-state stream, so a newer value COALESCES over an
356
+ // unconsumed older one. That also means a revoke (deliver `undefined`) drops any queued plaintext a
357
+ // slow consumer had not yet read — no stale secret is handed out after access is lost.
358
+ #queued = null;
359
+ // FIFO of parked next() resolvers, so concurrent next() calls each settle (a single slot would drop
360
+ // all but the last).
361
+ #waiters = [];
362
+ #closed = false;
363
+ /** Whether any value has been delivered yet, so the first (possibly `undefined`) value always emits. */
364
+ #hasEmitted = false;
365
+ /** Last value delivered, so an unrelated row change doesn't re-emit an unchanged value. */
366
+ lastValue;
367
+ componentName;
368
+ name;
369
+ constructor(componentName, name) {
370
+ this.componentName = componentName;
371
+ this.name = name;
372
+ }
373
+ get closed() {
374
+ return this.#closed;
375
+ }
376
+ [Symbol.asyncIterator]() {
377
+ return this;
378
+ }
379
+ next() {
380
+ if (this.#queued) {
381
+ const { value } = this.#queued;
382
+ this.#queued = null;
383
+ return Promise.resolve({ value, done: false });
384
+ }
385
+ if (this.#closed)
386
+ return Promise.resolve({ value: undefined, done: true });
387
+ return new Promise((resolve) => this.#waiters.push(resolve));
388
+ }
389
+ // Consumer breaking out of `for await` — tear down and stop tracking this stream.
390
+ return() {
391
+ this.#close();
392
+ return Promise.resolve({ value: undefined, done: true });
393
+ }
394
+ // Producer side: deliver the current effective value (bespoke iterator rather than IterableEventQueue
395
+ // so an `undefined`/empty-string value can't be misread as an empty queue). Dedups so an unrelated
396
+ // row change doesn't re-emit an unchanged value; the first value always emits so a currently-absent
397
+ // secret yields `undefined` up front.
398
+ deliver(value) {
399
+ if (this.#closed)
400
+ return;
401
+ if (this.#hasEmitted && value === this.lastValue)
402
+ return;
403
+ this.#hasEmitted = true;
404
+ this.lastValue = value;
405
+ const waiter = this.#waiters.shift();
406
+ if (waiter)
407
+ waiter({ value, done: false });
408
+ else
409
+ this.#queued = { value }; // replace: coalesce to the latest, dropping any stale queued value
410
+ }
411
+ // Producer side: end the stream (consumer/component gone, or the store is unavailable). Access loss
412
+ // is NOT an end — it is delivered as an `undefined` value via deliver().
413
+ end() {
414
+ this.#close();
415
+ }
416
+ #close() {
417
+ if (this.#closed)
418
+ return;
419
+ this.#closed = true;
420
+ // Drop any queued plaintext and the retained last value so nothing is readable after close.
421
+ this.#queued = null;
422
+ this.lastValue = undefined;
423
+ unregisterStream(this);
424
+ const waiters = this.#waiters;
425
+ this.#waiters = [];
426
+ for (const waiter of waiters)
427
+ waiter({ value: undefined, done: true });
428
+ }
429
+ }
430
+ // name → active streams for that name. Keyed by secret name so a change dispatches only to the
431
+ // streams that care, without every stream filtering every commit.
432
+ const secretSubscribers = new Map();
433
+ function registerStream(stream) {
434
+ let streams = secretSubscribers.get(stream.name);
435
+ if (!streams)
436
+ secretSubscribers.set(stream.name, (streams = new Set()));
437
+ streams.add(stream);
438
+ }
439
+ function unregisterStream(stream) {
440
+ const streams = secretSubscribers.get(stream.name);
441
+ if (!streams)
442
+ return;
443
+ streams.delete(stream);
444
+ if (streams.size === 0)
445
+ secretSubscribers.delete(stream.name);
446
+ }
296
447
  /**
297
- * The secrets view for a component: scoped-tier rows granted to it, plus its DECLARED names
298
- * resolved from process.env (global-tier materialized values, env literals, or real env vars).
299
- * The superset lets app code use `secrets.FOO` uniformly, and lets ops later tighten a secret from
300
- * global to granted without breaking the app. Values are decrypted eagerly at load; the object is
301
- * frozen and enumerable (`Object.keys`, spread).
448
+ * Resolve a component's effective view of one secret from a freshly-decrypted row state the SAME
449
+ * authority the read accessor applies (scoped rows require a grant; global/absent rows require a
450
+ * declaration), so the subscription can never widen access. Returns the value when available.
302
451
  */
303
- function getSecretsForComponent(componentName) {
304
- let view = accessorCache.get(componentName);
305
- if (view)
306
- return view;
307
- // Null prototype so inherited Object.prototype members (toString, hasOwnProperty, constructor)
308
- // can never masquerade as secret values under dynamic access like `secrets[key]`.
309
- const entries = Object.create(null);
310
- const declared = declaredEnvNames.get(componentName);
311
- if (declared) {
312
- for (const name of declared) {
313
- const value = process.env[name];
314
- if (value !== undefined)
315
- entries[name] = value;
452
+ function resolveSubscribedSecret(componentName, name, row) {
453
+ // `subscribe` is the reserved accessor method, never a readable secret (it is shadowed on the view),
454
+ // so it must be uniformly invisible as a value here too — otherwise subscribe('subscribe') would leak
455
+ // the plaintext of a secret literally named `subscribe` that the accessor hides.
456
+ if (name === 'subscribe')
457
+ return { available: false };
458
+ // Exactly the read accessor's authority (getSecretsForComponent), so a subscription can never
459
+ // widen access beyond a fresh `secrets[name]` read:
460
+ // 1. a scoped row granted to this component and decryptable → its live decrypted value;
461
+ // 2. otherwise, a name this component DECLARED → its process.env value (global-materialized,
462
+ // literal, or a real env var — reload-only, precedence already resolved in process.env).
463
+ // The process.env fallback is gated on declaration: an undeclared, ungranted component must never
464
+ // read a still-materialized global value out of process.env (that would bypass the grant model).
465
+ if (row && !row.processEnv && row.value !== undefined && row.grants.includes(componentName)) {
466
+ return { available: true, value: row.value };
467
+ }
468
+ if (declaredEnvNames.get(componentName)?.has(name)) {
469
+ const envValue = process.env[name];
470
+ if (envValue !== undefined)
471
+ return { available: true, value: envValue };
472
+ }
473
+ return { available: false };
474
+ }
475
+ // The one shared subscription to system.hdb_secret. Lazily started on the first subscribe; a
476
+ // single watcher feeds every stream.
477
+ let secretWatcher;
478
+ let secretWatcherSubscription;
479
+ function restartSecretWatcher() {
480
+ // Drop the memo (and any live subscription) so the next ensureSecretWatcher() re-attaches.
481
+ const sub = secretWatcherSubscription;
482
+ secretWatcher = undefined;
483
+ secretWatcherSubscription = undefined;
484
+ sub?.emit?.('close');
485
+ sub?.end?.();
486
+ }
487
+ function ensureSecretWatcher(table) {
488
+ // Reset the memo on failure so a transient table.subscribe() error doesn't permanently disable live
489
+ // secrets — the next caller retries instead of reusing a rejected promise.
490
+ return (secretWatcher ??= (async () => {
491
+ // omitCurrent: the load-cycle scan (scanIntoSecretRows) seeds and re-syncs the snapshot, and the
492
+ // watcher is attached BEFORE that scan, so it need not (and should not) replay current rows here —
493
+ // it only needs subsequent commits, local or replicated.
494
+ secretWatcherSubscription = await table.subscribe({ omitCurrent: true, listener: onSecretEvent });
495
+ })().catch((error) => {
496
+ secretWatcher = undefined;
497
+ throw error;
498
+ }));
499
+ }
500
+ // Fired for every commit to system.hdb_secret (local or replicated). event.value is the full record
501
+ // on put; null-valued on delete. Keeps `secretRows` (the live view's source) current and fans the
502
+ // change out to any subscribers, WITHOUT touching process.env — the global tier stays reload-only.
503
+ function onSecretEvent(event) {
504
+ try {
505
+ // Table.subscribe surfaces an async retained-state/replay failure by delivering the Error THROUGH
506
+ // the listener (not by rejecting subscribe()), so detect it here and restart the watcher —
507
+ // otherwise the memo stays fulfilled and every future ensureSecretWatcher reuses a dead subscription.
508
+ if (event instanceof Error) {
509
+ harper_logger_ts_1.default.warn(`secrets change watcher errored; restarting it: ${event.message}`);
510
+ restartSecretWatcher();
511
+ return;
316
512
  }
513
+ // Base-copy replication of a system table delivers a single `{type:'reload', id:null}` marker
514
+ // instead of per-row events (harper-pro#495): the whole table was reseeded, so re-scan the LIVE
515
+ // snapshot to resync adds AND removals (and re-deliver to streams). It does NOT re-materialize
516
+ // process.env — the global tier stays reload-only even under replicated reloads.
517
+ if (event?.type === 'reload') {
518
+ const table = databases_ts_1.databases.system?.[SECRET_TABLE];
519
+ if (table)
520
+ scanIntoSecretRows(table).catch((error) => harper_logger_ts_1.default.warn(`secrets store rescan after a reload marker failed: ${error.message}`));
521
+ return;
522
+ }
523
+ const name = (event?.value?.name ?? event?.id);
524
+ if (typeof name !== 'string' || !name)
525
+ return;
526
+ secretEventSeq++; // let a concurrent rescan know its snapshot is now stale
527
+ const record = event?.type === 'delete' ? null : (event?.value ?? null);
528
+ const row = record ? rowStateFromRecord(record) : undefined;
529
+ if (row)
530
+ secretRows.set(name, row);
531
+ else
532
+ secretRows.delete(name);
533
+ // The cached views are live proxies (they read secretRows on each access), so no invalidation is
534
+ // needed here — a fresh `secrets[name]` read already reflects this update.
535
+ dispatchSecretChange(name, row);
317
536
  }
318
- // Scoped rows granted to this component; on a name collision the scoped value wins.
319
- for (const [name, row] of secretRows) {
320
- if (row.value !== undefined && !row.processEnv && row.grants.includes(componentName)) {
321
- entries[name] = row.value;
537
+ catch (error) {
538
+ harper_logger_ts_1.default.warn(`secrets change subscription failed to dispatch an event: ${error.message}`);
539
+ }
540
+ }
541
+ function dispatchSecretChange(name, row) {
542
+ const streams = secretSubscribers.get(name);
543
+ if (!streams || streams.size === 0)
544
+ return;
545
+ for (const stream of [...streams]) {
546
+ // Authority is re-evaluated on EVERY event (continuous re-authorization, the property #1414
547
+ // enforces): a revoke/delete/custody-loss resolves to unavailable → the stream is handed
548
+ // `undefined` (no plaintext retained), not the last value. A later re-grant/re-add resolves to a
549
+ // value again and resumes on the same stream. deliver() dedups unchanged values.
550
+ const { available, value } = resolveSubscribedSecret(stream.componentName, name, row);
551
+ stream.deliver(available ? value : undefined);
552
+ }
553
+ }
554
+ // Re-deliver the current effective value to every open stream from the present `secretRows` — used
555
+ // after a full rescan (reload marker or load cycle), where individual per-row events were not seen so
556
+ // a removed row's subscribers would otherwise keep their stale value.
557
+ function redispatchAllStreams() {
558
+ for (const [name, streams] of secretSubscribers) {
559
+ const row = secretRows.get(name);
560
+ for (const stream of [...streams]) {
561
+ const { available, value } = resolveSubscribedSecret(stream.componentName, name, row);
562
+ stream.deliver(available ? value : undefined);
322
563
  }
323
564
  }
324
- view = Object.freeze(entries);
565
+ }
566
+ /**
567
+ * `secrets.subscribe(name)` for a component: the current effective value (matching `secrets[name]`)
568
+ * then a stream of changes. Returned synchronously for direct use in `for await`. The initial value is
569
+ * delivered SYNCHRONOUSLY from the live snapshot before the watcher is (re)started, so no change event
570
+ * can interleave ahead of it and a stale value can never land after a newer one.
571
+ */
572
+ function subscribeSecret(componentName, name) {
573
+ const stream = new SecretChangeStream(componentName, name);
574
+ registerStream(stream);
575
+ // Initial value from the current snapshot (scoped-granted live, or a declared name's process.env
576
+ // value even when the store is unavailable — accessor-equivalent).
577
+ const initial = resolveSubscribedSecret(componentName, name, secretRows.get(name));
578
+ stream.deliver(initial.available ? initial.value : undefined);
579
+ const table = databases_ts_1.databases.system?.[SECRET_TABLE];
580
+ if (table) {
581
+ // Start (or reuse) the one shared watcher so subsequent changes flow to this stream.
582
+ ensureSecretWatcher(table).catch((error) => {
583
+ harper_logger_ts_1.default.warn(`secrets.subscribe('${name}'): live change watcher unavailable: ${error.message}`);
584
+ });
585
+ }
586
+ else {
587
+ // No store to watch: the stream keeps its initial value and simply won't receive live updates
588
+ // (nothing can change without the store); a reload/restart re-establishes it.
589
+ harper_logger_ts_1.default.debug?.(`secrets.subscribe('${name}'): secrets store (system.${SECRET_TABLE}) is unavailable; no live updates`);
590
+ }
591
+ return stream;
592
+ }
593
+ /** Close every live subscription owned by a component (component reload/unload). */
594
+ function closeComponentSubscriptions(componentName) {
595
+ // Collect first, then close: stream.end() → unregisterStream() mutates secretSubscribers, so we must
596
+ // not close while iterating it.
597
+ const toClose = [];
598
+ for (const streams of secretSubscribers.values()) {
599
+ for (const stream of streams)
600
+ if (stream.componentName === componentName)
601
+ toClose.push(stream);
602
+ }
603
+ for (const stream of toClose)
604
+ stream.end();
605
+ }
606
+ // Subscriptions are keyed by component IDENTITY (applicationScope.name), which several concurrently-open
607
+ // Scopes legitimately share — most notably a throwaway deploy-validation Scope loads the SAME directory
608
+ // as the running app and closes in a `finally` before the real restart. Tearing down on any one Scope's
609
+ // close would kill the running app's live streams. So teardown is reference-counted per identity: a Scope
610
+ // retains on construction and releases on close, and streams are ended only when the LAST holder of that
611
+ // identity releases (i.e. the app is truly unloading, not merely a validation load being discarded).
612
+ const subscriptionHolders = new Map();
613
+ /** A Scope of this identity is now open — hold its live secret subscriptions until it releases. */
614
+ function retainComponentSubscriptions(componentName) {
615
+ subscriptionHolders.set(componentName, (subscriptionHolders.get(componentName) ?? 0) + 1);
616
+ }
617
+ /** A Scope of this identity closed — end the identity's subscriptions once the last holder is gone. */
618
+ function releaseComponentSubscriptions(componentName) {
619
+ const remaining = (subscriptionHolders.get(componentName) ?? 0) - 1;
620
+ if (remaining > 0) {
621
+ subscriptionHolders.set(componentName, remaining);
622
+ }
623
+ else {
624
+ subscriptionHolders.delete(componentName);
625
+ closeComponentSubscriptions(componentName);
626
+ }
627
+ }
628
+ /** The component's current effective value for one name (scoped-granted live, else declared→process.env). */
629
+ function currentSecretValue(componentName, name) {
630
+ const { available, value } = resolveSubscribedSecret(componentName, name, secretRows.get(name));
631
+ return available ? value : undefined;
632
+ }
633
+ /**
634
+ * Whether a secret named exactly `name` WOULD be readable to the component if it weren't reserved —
635
+ * used only to detect (and warn about) a secret named `subscribe` shadowed by the reserved method.
636
+ */
637
+ function hasRawSecretNamed(componentName, name) {
638
+ const row = secretRows.get(name);
639
+ if (row && !row.processEnv && row.value !== undefined && row.grants.includes(componentName))
640
+ return true;
641
+ return (declaredEnvNames.get(componentName)?.has(name) ?? false) && process.env[name] !== undefined;
642
+ }
643
+ /** The names currently visible to a component (granted scoped rows + declared names present in env). */
644
+ function currentSecretNames(componentName) {
645
+ const names = new Set();
646
+ const declared = declaredEnvNames.get(componentName);
647
+ if (declared)
648
+ for (const name of declared)
649
+ if (process.env[name] !== undefined)
650
+ names.add(name);
651
+ for (const [name, row] of secretRows) {
652
+ if (row.value !== undefined && !row.processEnv && row.grants.includes(componentName))
653
+ names.add(name);
654
+ }
655
+ names.delete('subscribe'); // reserved for the method — never surfaced as a value key
656
+ return [...names];
657
+ }
658
+ /**
659
+ * The secrets view for a component: scoped-tier rows granted to it, plus its DECLARED names resolved
660
+ * from process.env (global-tier materialized values, env literals, or real env vars). The superset
661
+ * lets app code use `secrets.FOO` uniformly, and lets ops later tighten a secret from global to
662
+ * granted without breaking the app.
663
+ *
664
+ * It is a LIVE view (#1776): a Proxy that resolves each read from the current snapshot, so a scoped
665
+ * secret's rotation/revoke is reflected by a fresh `secrets.FOO` read under every loader — including a
666
+ * view captured once by the vm/compartment loader or held as `const v = scope.secrets` (only a
667
+ * value destructured out, `const { FOO } = secrets`, is a point-in-time copy). Declared global names
668
+ * still resolve from `process.env` (reload-only). Read-only and null-prototype, so Object.prototype
669
+ * members can't masquerade as secrets and the view can't be mutated. `subscribe` is a reserved,
670
+ * non-enumerable method (skipped by Object.keys/spread); a secret literally named `subscribe` is
671
+ * shadowed by it — logged once when observed.
672
+ */
673
+ function getSecretsForComponent(componentName) {
674
+ let view = accessorCache.get(componentName);
675
+ if (view)
676
+ return view;
677
+ const subscribe = (name) => subscribeSecret(componentName, name);
678
+ view = new Proxy(Object.create(null), {
679
+ get(_target, property) {
680
+ if (property === 'subscribe') {
681
+ // Advisory: warn once per component if a real secret named `subscribe` is shadowed by the
682
+ // reserved method (it is then unreadable via dot/bracket access — an accepted #1776 tradeoff).
683
+ if (!warnedSubscribeShadow.has(componentName)) {
684
+ warnedSubscribeShadow.add(componentName);
685
+ if (hasRawSecretNamed(componentName, 'subscribe')) {
686
+ harper_logger_ts_1.default.warn(`Component '${componentName}' has a secret named 'subscribe'; it is shadowed by the reserved secrets.subscribe() method and is not readable through the secrets accessor`);
687
+ }
688
+ }
689
+ return subscribe;
690
+ }
691
+ if (typeof property === 'symbol')
692
+ return undefined;
693
+ return currentSecretValue(componentName, property);
694
+ },
695
+ has(_target, property) {
696
+ if (property === 'subscribe')
697
+ return true;
698
+ if (typeof property === 'symbol')
699
+ return false;
700
+ return currentSecretValue(componentName, property) !== undefined;
701
+ },
702
+ ownKeys() {
703
+ // Include `subscribe` so own-property reflection is consistent (getOwnPropertyDescriptor
704
+ // reports it); it is non-enumerable, so spread/Object.keys still skip it.
705
+ return [...currentSecretNames(componentName), 'subscribe'];
706
+ },
707
+ getOwnPropertyDescriptor(_target, property) {
708
+ if (property === 'subscribe') {
709
+ return { value: subscribe, writable: false, enumerable: false, configurable: true };
710
+ }
711
+ if (typeof property === 'symbol')
712
+ return undefined;
713
+ const value = currentSecretValue(componentName, property);
714
+ if (value === undefined)
715
+ return undefined;
716
+ return { value, writable: false, enumerable: true, configurable: true };
717
+ },
718
+ set: readOnly,
719
+ defineProperty: readOnly,
720
+ deleteProperty: readOnly,
721
+ preventExtensions: readOnly,
722
+ setPrototypeOf: readOnly,
723
+ });
325
724
  accessorCache.set(componentName, view);
326
725
  return view;
327
726
  }
@@ -356,6 +755,9 @@ function readOnly() {
356
755
  * spread) never crash the process — only direct property reads are loud.
357
756
  */
358
757
  exports.secrets = new Proxy({}, {
758
+ // `subscribe` resolves like any other own property of the bound view (it is a non-enumerable
759
+ // method there); it therefore reads through here, appears in ownKeys as non-enumerable, and is
760
+ // skipped by spread/Object.keys — no special-casing needed in the traps.
359
761
  get(_target, property) {
360
762
  if (typeof property === 'symbol' || property === 'then')
361
763
  return undefined;
@@ -377,8 +779,7 @@ exports.secrets = new Proxy({}, {
377
779
  if (typeof property === 'symbol' || componentBinding.getStore() === undefined)
378
780
  return undefined;
379
781
  const descriptor = Object.getOwnPropertyDescriptor(resolveBoundSecrets(), property);
380
- // The views are frozen; report configurable so the proxy invariant against its (extensible,
381
- // empty) target holds.
782
+ // Report configurable so the proxy invariant against this (extensible, empty) target holds.
382
783
  return descriptor && { ...descriptor, configurable: true };
383
784
  },
384
785
  set: readOnly,
@@ -389,7 +790,9 @@ exports.secrets = new Proxy({}, {
389
790
  // target and break enumeration for every later consumer.
390
791
  preventExtensions: readOnly,
391
792
  setPrototypeOf: readOnly,
392
- });
793
+ }
794
+ // The empty target can't carry the reserved `subscribe` member statically; the get trap supplies it.
795
+ );
393
796
  (0, globals_js_1._assignPackageExport)('secrets', exports.secrets);
394
797
  /** Reset all module state and retract materialized env values. Intended for tests. */
395
798
  function resetComponentSecrets() {
@@ -401,5 +804,16 @@ function resetComponentSecrets() {
401
804
  declaredEnvNames.clear();
402
805
  unsatisfiedEnv.clear();
403
806
  accessorCache.clear();
807
+ warnedSubscribeShadow.clear();
808
+ const openStreams = [...secretSubscribers.values()].flatMap((streams) => [...streams]);
809
+ secretSubscribers.clear();
810
+ subscriptionHolders.clear();
811
+ for (const stream of openStreams)
812
+ stream.end();
813
+ // Tear down the shared table watcher too, so a reset doesn't leak an audit-log listener.
814
+ secretWatcherSubscription?.emit?.('close');
815
+ secretWatcherSubscription?.end?.();
816
+ secretWatcher = undefined;
817
+ secretWatcherSubscription = undefined;
404
818
  }
405
819
  //# sourceMappingURL=componentSecrets.js.map