renor-engine 0.0.0-stage → 1.0.0

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 (176) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +73 -0
  3. package/README.md +105 -2
  4. package/dist/BUILD.json +6 -0
  5. package/dist/api/apiAuth.js +43 -0
  6. package/dist/api/server.js +172 -0
  7. package/dist/buildIdentity.js +132 -0
  8. package/dist/cli-extractors.js +46 -0
  9. package/dist/cli.js +491 -0
  10. package/dist/config.js +65 -0
  11. package/dist/context/composer.js +320 -0
  12. package/dist/context/readHonesty.js +478 -0
  13. package/dist/context/rowStore.js +238 -0
  14. package/dist/conversation/formatter.js +81 -0
  15. package/dist/conversation/parser.js +189 -0
  16. package/dist/conversation/types.js +2 -0
  17. package/dist/conversation/writer.js +42 -0
  18. package/dist/db/client.js +147 -0
  19. package/dist/db/indexSalvage.js +81 -0
  20. package/dist/db/migrations.js +642 -0
  21. package/dist/db/readCache.js +109 -0
  22. package/dist/db/schema.js +5 -0
  23. package/dist/db/statements.js +89 -0
  24. package/dist/digests/digestCache.js +142 -0
  25. package/dist/digests/digests.js +597 -0
  26. package/dist/digests/worker.js +348 -0
  27. package/dist/embeddings/caching.js +337 -0
  28. package/dist/embeddings/coalescing.js +177 -0
  29. package/dist/embeddings/interactive.js +103 -0
  30. package/dist/embeddings/local.js +68 -0
  31. package/dist/embeddings/openai.js +105 -0
  32. package/dist/embeddings/rebuild.js +166 -0
  33. package/dist/embeddings/simdScan.js +258 -0
  34. package/dist/embeddings/space.js +21 -0
  35. package/dist/embeddings/types.js +14 -0
  36. package/dist/embeddings/vectorIndex.js +675 -0
  37. package/dist/embeddings/vertex.js +224 -0
  38. package/dist/eval/artifacts.js +42 -0
  39. package/dist/eval/cacheReport.js +139 -0
  40. package/dist/eval/comparison.js +182 -0
  41. package/dist/eval/context700-candidate.js +145 -0
  42. package/dist/eval/evalCache.js +525 -0
  43. package/dist/eval/hashEmbedding.js +35 -0
  44. package/dist/eval/longmemeval.js +56 -0
  45. package/dist/eval/metrics.js +181 -0
  46. package/dist/eval/renorbenchBaseline.js +486 -0
  47. package/dist/eval/renorbenchDocuments.js +134 -0
  48. package/dist/eval/renorbenchProduct.js +182 -0
  49. package/dist/eval/runLedger.js +279 -0
  50. package/dist/eval/runManifest.js +479 -0
  51. package/dist/eval/scoring.js +197 -0
  52. package/dist/evidence/attach.js +267 -0
  53. package/dist/evidence/background.js +147 -0
  54. package/dist/evidence/evidenceCache.js +182 -0
  55. package/dist/evidence/gate.js +37 -0
  56. package/dist/evidence/keyPool.js +85 -0
  57. package/dist/evidence/ollamaChat.js +84 -0
  58. package/dist/evidence/optin.js +165 -0
  59. package/dist/evidence/options.js +130 -0
  60. package/dist/evidence/secondPass.js +456 -0
  61. package/dist/extraction/classifyCapture.js +34 -0
  62. package/dist/extraction/decision-model.js +174 -0
  63. package/dist/extraction/eventExtractor.js +113 -0
  64. package/dist/extraction/extractionClient.js +122 -0
  65. package/dist/extraction/factExtractor.js +46 -0
  66. package/dist/extraction/factsWritePath.js +179 -0
  67. package/dist/extraction/preferenceExtractor.js +153 -0
  68. package/dist/extraction/secretGuard.js +176 -0
  69. package/dist/extraction/secretScanner.js +665 -0
  70. package/dist/extraction/typedFacts.js +224 -0
  71. package/dist/extraction/writePathAdjudication.js +110 -0
  72. package/dist/importing/apply.js +644 -0
  73. package/dist/importing/bundle-write.js +80 -0
  74. package/dist/importing/cli.js +407 -0
  75. package/dist/importing/existing.js +49 -0
  76. package/dist/importing/facts.js +123 -0
  77. package/dist/importing/forgetImported.js +79 -0
  78. package/dist/importing/hosted.js +467 -0
  79. package/dist/importing/index.js +29 -0
  80. package/dist/importing/journalLog.js +96 -0
  81. package/dist/importing/markdown.js +98 -0
  82. package/dist/importing/plan.js +206 -0
  83. package/dist/importing/secretScreen.js +113 -0
  84. package/dist/importing/serialize.js +188 -0
  85. package/dist/importing/source.js +463 -0
  86. package/dist/importing/types.js +6 -0
  87. package/dist/indexer/chunker.js +319 -0
  88. package/dist/indexer/dailyEntries.js +63 -0
  89. package/dist/indexer/hasher.js +10 -0
  90. package/dist/indexer/laneRuns.js +48 -0
  91. package/dist/indexer/o200k.js +481 -0
  92. package/dist/indexer/o200k.json +8 -0
  93. package/dist/indexer/outsideRoot.js +77 -0
  94. package/dist/indexer/parser.js +167 -0
  95. package/dist/indexer/pipeline.js +1168 -0
  96. package/dist/indexer/staleness.js +201 -0
  97. package/dist/indexer/tokenizer.js +77 -0
  98. package/dist/indexer/walker.js +184 -0
  99. package/dist/mcp/hive.js +88 -0
  100. package/dist/mcp/requestErrors.js +47 -0
  101. package/dist/mcp/server.js +396 -0
  102. package/dist/memory/agentLease.js +43 -0
  103. package/dist/memory/confinedWrite.js +315 -0
  104. package/dist/memory/controls.js +73 -0
  105. package/dist/memory/dailyNote.js +265 -0
  106. package/dist/memory/durableAppend.js +57 -0
  107. package/dist/memory/factConflicts.js +203 -0
  108. package/dist/memory/factTerms.js +321 -0
  109. package/dist/memory/hiveNameHeader.cjs +10 -0
  110. package/dist/memory/inventory.js +55 -0
  111. package/dist/memory/invisibleText.js +20 -0
  112. package/dist/memory/lifecycle.js +1756 -0
  113. package/dist/memory/limits.js +13 -0
  114. package/dist/memory/requests.js +183 -0
  115. package/dist/memory/sourceName.js +200 -0
  116. package/dist/memory/whatsNew.js +158 -0
  117. package/dist/memory/writer.js +175 -0
  118. package/dist/memory/writerConflicts.js +85 -0
  119. package/dist/memorymap/memoryMap.js +1568 -0
  120. package/dist/memorymap/replacedJournal.js +58 -0
  121. package/dist/memorymap/replacements.js +405 -0
  122. package/dist/mvp/activity.js +86 -0
  123. package/dist/mvp/agentMemory.js +1744 -0
  124. package/dist/mvp/auth.js +531 -0
  125. package/dist/mvp/cli.js +73 -0
  126. package/dist/mvp/client-store.js +462 -0
  127. package/dist/mvp/compileCache.js +249 -0
  128. package/dist/mvp/compileSeed.js +27 -0
  129. package/dist/mvp/config.js +288 -0
  130. package/dist/mvp/connectionStatus.js +80 -0
  131. package/dist/mvp/contextRelevance.js +413 -0
  132. package/dist/mvp/engine.js +1065 -0
  133. package/dist/mvp/exportAll.js +359 -0
  134. package/dist/mvp/factLanePack.js +77 -0
  135. package/dist/mvp/factMarkers.js +49 -0
  136. package/dist/mvp/factsLane.js +203 -0
  137. package/dist/mvp/hiddenSpans.js +585 -0
  138. package/dist/mvp/history.js +413 -0
  139. package/dist/mvp/hitDates.js +31 -0
  140. package/dist/mvp/hitFloor.js +36 -0
  141. package/dist/mvp/identity.js +18 -0
  142. package/dist/mvp/idle.js +113 -0
  143. package/dist/mvp/memoryDir.js +98 -0
  144. package/dist/mvp/posthogMcp.js +210 -0
  145. package/dist/mvp/readScope.js +104 -0
  146. package/dist/mvp/readUsefulness.js +222 -0
  147. package/dist/mvp/sanitize.js +48 -0
  148. package/dist/mvp/schemaRejections.js +91 -0
  149. package/dist/mvp/scope.js +127 -0
  150. package/dist/mvp/server.js +2089 -0
  151. package/dist/mvp/serverCard.js +29 -0
  152. package/dist/mvp/storedSize.js +139 -0
  153. package/dist/mvp/telemetry.js +441 -0
  154. package/dist/retrieval/adviceLines.js +195 -0
  155. package/dist/retrieval/adviceQuestion.js +44 -0
  156. package/dist/retrieval/conversationShape.js +44 -0
  157. package/dist/retrieval/dailyEntryTime.js +55 -0
  158. package/dist/retrieval/entityLookup.js +113 -0
  159. package/dist/retrieval/factsFirst.js +188 -0
  160. package/dist/retrieval/keyword.js +84 -0
  161. package/dist/retrieval/ledgerEventTime.js +51 -0
  162. package/dist/retrieval/ledgerLiveness.js +83 -0
  163. package/dist/retrieval/ledgerPresentation.js +161 -0
  164. package/dist/retrieval/noteIdentity.js +57 -0
  165. package/dist/retrieval/orchestrator.js +1086 -0
  166. package/dist/retrieval/queryHelpers.js +142 -0
  167. package/dist/retrieval/questionSpan.js +475 -0
  168. package/dist/retrieval/readerEvents.js +690 -0
  169. package/dist/retrieval/readerLayout.js +503 -0
  170. package/dist/retrieval/relativeTimeHints.js +113 -0
  171. package/dist/retrieval/semantic.js +67 -0
  172. package/dist/router/routerCache.js +106 -0
  173. package/dist/router/sessionRouter.js +726 -0
  174. package/dist/toolRefusal.js +120 -0
  175. package/dist/watcher/watcher.js +75 -0
  176. package/package.json +120 -4
@@ -0,0 +1,462 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ClientRegistry = exports.ClientRegistryError = exports.PENDING_CLIENT_APPROVAL_TTL_MS = exports.MAX_PERSISTED_CLIENTS = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ const sourceName_1 = require("../memory/sourceName");
11
+ /**
12
+ * Persistent OAuth client registry (CLIENT-PERSISTENCE fix).
13
+ *
14
+ * The MVP previously kept the DCR client registry in memory only. ChatGPT
15
+ * runs dynamic client registration ONCE per connection and caches the
16
+ * returned client_id, so a service restart wiped the registry and every
17
+ * later authorization failed with invalid_client — the owner could never
18
+ * re-authorize without the client re-registering. This store persists ONLY
19
+ * registered OAuth client metadata (client_id, client_secret, redirect
20
+ * URIs, grant types) across restarts. Authorization tickets and codes are
21
+ * deliberately NOT persisted: they stay short-lived and single-use. Access
22
+ * and refresh tokens also stay ephemeral for this bounded fix — after a
23
+ * restart the owner must re-run the login once; the durable client
24
+ * identity is what makes that re-authorization possible.
25
+ *
26
+ * Hard requirements implemented here:
27
+ * - explicit private path, validated OUTSIDE the memory/history trees at
28
+ * config time; the store directory ("root") must be 0700/no group or
29
+ * other bits, owned by the running uid, a real directory rather than a
30
+ * symlink, and reached through no symlink except a root-owned one that
31
+ * group and other cannot write (macOS's /var -> private/var), and then
32
+ * only on a path where every directory belongs to root or the running
33
+ * uid and group and other cannot write it;
34
+ * - store file must be a regular, non-symlink, owner-only (0600) file;
35
+ * - versioned, strictly validated schema — corrupt, unreadable, or
36
+ * schema-violating existing state FAILS CLOSED (startup error), never
37
+ * a silent reset to empty;
38
+ * - bounded registry (MAX_REGISTERED_CLIENTS);
39
+ * - atomic durable writes: temp file 0600 -> fsync -> rename -> dir fsync;
40
+ * registration succeeds only AFTER the record is durably on disk — an
41
+ * I/O failure throws and leaves NO in-memory-only acknowledged client;
42
+ * - single-process serialization: all mutations are synchronous FS
43
+ * operations inside one Node event loop, so concurrent registrations
44
+ * cannot interleave reads-modify-writes;
45
+ * - nothing here ever logs client secrets or full client IDs.
46
+ */
47
+ const STORE_SCHEMA = 'mvp-oauth-clients/1';
48
+ /** Client store bound (AUTH-H2); a store violating it is corrupt, not trimmed. */
49
+ exports.MAX_PERSISTED_CLIENTS = 100;
50
+ /** Unapproved dynamic registrations are discarded after this owner-approval window. */
51
+ exports.PENDING_CLIENT_APPROVAL_TTL_MS = 10 * 60_000;
52
+ /** Accesses trigger expiry cleanup, throttled to avoid rewriting the store more than once a minute. */
53
+ const EXPIRED_CLIENT_SWEEP_INTERVAL_MS = 60_000;
54
+ /** Operator-facing failure. Messages carry no secrets. */
55
+ class ClientRegistryError extends Error {
56
+ constructor(message) {
57
+ super(message);
58
+ this.name = 'ClientRegistryError';
59
+ }
60
+ }
61
+ exports.ClientRegistryError = ClientRegistryError;
62
+ function failClosed(reason) {
63
+ throw new ClientRegistryError(`OAuth client store is not usable: ${reason}. `
64
+ + 'Refusing to start with a missing, corrupted, or unsafe client registry '
65
+ + '(fail closed). Fix or remove the store file only after backing it up, '
66
+ + 'then let clients register again; removed clients must be re-created by their owners.');
67
+ }
68
+ /** Running uid; the POSIX-only store refuses to run without one. */
69
+ function currentUid() {
70
+ const uid = process.getuid?.();
71
+ if (uid === undefined)
72
+ failClosed('process uid is unavailable on this platform');
73
+ return uid;
74
+ }
75
+ const ROOT_UID = 0;
76
+ const GROUP_OR_OTHER_WRITE = 0o022;
77
+ const STICKY = 0o1000;
78
+ /** Linux follows at most 40 symlinks in one path lookup, macOS 32. */
79
+ const MAX_PATH_SYMLINKS = 40;
80
+ const SYMLINK_RULE = 'store directory must not contain symlinks, except root-owned ones that group and other cannot write, '
81
+ + 'on a path that only root and the service user can change';
82
+ /** Why the symlink at `here`, which `found` describes, may not be followed, or null when it may. */
83
+ function linkRefusal(here, found) {
84
+ if (found.uid !== ROOT_UID)
85
+ return 'a symlink on its path is not owned by root';
86
+ if (found.mode & GROUP_OR_OTHER_WRITE)
87
+ return 'a symlink on its path is writable by group or other';
88
+ const leadsTo = fs_1.default.lstatSync(fs_1.default.realpathSync(here));
89
+ if (leadsTo.uid !== ROOT_UID)
90
+ return 'a symlink on its path leads to a directory that is not owned by root';
91
+ if (leadsTo.mode & GROUP_OR_OTHER_WRITE)
92
+ return 'a symlink on its path leads to a directory that is writable by group or other';
93
+ return null;
94
+ }
95
+ /**
96
+ * Why `dir`, which the path searches for the entry `found`, may not be on it, or null when it may. Whoever can write a
97
+ * directory can rename or replace the entries in it, so it is root's or the service user's and closed to group and
98
+ * other. A directory they can write is accepted only when it is sticky and root's, for an entry root owns, because
99
+ * then only root may rename or remove that entry (/tmp is drwxrwxrwt).
100
+ */
101
+ function directoryRefusal(dir, found) {
102
+ if (dir.uid !== ROOT_UID && dir.uid !== currentUid())
103
+ return 'a directory on its path is not owned by root or the service user';
104
+ if ((dir.mode & GROUP_OR_OTHER_WRITE) === 0)
105
+ return null;
106
+ if ((dir.mode & STICKY) !== 0 && dir.uid === ROOT_UID && found.uid === ROOT_UID)
107
+ return null;
108
+ return 'a directory on its path is writable by group or other';
109
+ }
110
+ /**
111
+ * Why the path to `dir` may not be used, or null when it may. These messages reach the /register response, so none
112
+ * names a path.
113
+ *
114
+ * A path without a symlink is judged as it always was: by the store directory itself, in validateStoreRoot. A symlink on
115
+ * the path is refused unless root owns it and group and other cannot write it, and the same holds for the directory it
116
+ * leads to: macOS reaches its per-user temp directory through /var, a symlink to private/var. Linux reports every link
117
+ * as lrwxrwxrwx, so there this still refuses all of them.
118
+ *
119
+ * A link is only as safe as the directory it sits in, and its target only as safe as every directory on the way to it:
120
+ * whoever can write one of those can rename or replace what was just checked, and a path that was refused outright
121
+ * before would be accepted. So the walk follows the path as the kernel does, one component at a time, and judges each
122
+ * link and every directory a name is looked up in, those a link leads through included (/private is in no path an
123
+ * operator writes, only in the target of /var). What nobody else can change nobody else can swap between this check and
124
+ * the write that follows, which is why the write needs no descriptor of the directory (Node has no openat). Every
125
+ * directory above the store is walked, and the walk must end where realpath does, or the path is not trusted.
126
+ *
127
+ * Owner and mode are all this sees. A bind mount looks like the directory it mounts and is judged as that directory,
128
+ * but lstat and realpath cannot tell whether a mount sits on the path. Mounting over a directory takes root or owning
129
+ * it, and every directory accepted here belongs to root or the service user, so the mount layout is the operator's to
130
+ * protect, as root's own links are.
131
+ */
132
+ function symlinkRefusal(dir) {
133
+ const real = fs_1.default.realpathSync(dir);
134
+ if (real === path_1.default.resolve(dir))
135
+ return null;
136
+ // At least one symlink is on the way. Follow the path as the kernel does, one component at a time.
137
+ let at = path_1.default.sep;
138
+ const absolute = path_1.default.isAbsolute(dir) ? dir : `${process.cwd()}${path_1.default.sep}${dir}`;
139
+ let rest = absolute.split(path_1.default.sep);
140
+ let links = 0;
141
+ while (rest.length > 0) {
142
+ const name = rest.shift();
143
+ if (name === '' || name === '.')
144
+ continue;
145
+ if (name === '..') {
146
+ at = path_1.default.dirname(at);
147
+ continue;
148
+ }
149
+ const here = path_1.default.join(at, name);
150
+ const found = fs_1.default.lstatSync(here);
151
+ const isLink = found.isSymbolicLink();
152
+ if (isLink && ++links > MAX_PATH_SYMLINKS)
153
+ return `${SYMLINK_RULE}; its path crosses too many symlinks`;
154
+ const unsafe = (isLink ? linkRefusal(here, found) : null) ?? directoryRefusal(fs_1.default.lstatSync(at), found);
155
+ if (unsafe !== null)
156
+ return `${SYMLINK_RULE}; ${unsafe}`;
157
+ if (!isLink) {
158
+ at = here;
159
+ continue;
160
+ }
161
+ const target = fs_1.default.readlinkSync(here);
162
+ if (path_1.default.isAbsolute(target))
163
+ at = path_1.default.sep;
164
+ rest = [...target.split(path_1.default.sep), ...rest];
165
+ }
166
+ // The walk judged what it went through and the kernel says where the path really goes. If the two differ, the walk did
167
+ // not see what the kernel saw (a link changed under it, or a case it does not model), and that is not a path to trust.
168
+ return at === real ? null : 'store directory could not be resolved consistently';
169
+ }
170
+ function validateStoreRoot(dir) {
171
+ let dirStat;
172
+ try {
173
+ dirStat = fs_1.default.lstatSync(dir);
174
+ }
175
+ catch (err) {
176
+ failClosed(`store directory is missing or unreadable (${err.code}); `
177
+ + 'the operator must create it as an owner-only (0700) directory before startup');
178
+ }
179
+ if (!dirStat.isDirectory())
180
+ failClosed('store path parent is not a directory');
181
+ if (dirStat.uid !== currentUid())
182
+ failClosed('store directory is not owned by the service user');
183
+ if (dirStat.mode & 0o077)
184
+ failClosed('store directory must be owner-only (0700)');
185
+ // The directory itself is a real, owner-only directory of the service user. What is left is the path to it. The
186
+ // refusal is judged outside the try, so its reason cannot be replaced by the one for a failed lookup.
187
+ let refusal;
188
+ try {
189
+ refusal = symlinkRefusal(dir);
190
+ }
191
+ catch (err) {
192
+ failClosed(`store directory could not be resolved (${err.code ?? 'io-error'})`);
193
+ }
194
+ if (refusal !== null)
195
+ failClosed(refusal);
196
+ }
197
+ function validateStoreFile(filePath) {
198
+ const stat = fs_1.default.lstatSync(filePath); // throws if missing (handled by caller)
199
+ if (!stat.isFile())
200
+ failClosed('store path is not a regular file (symlinks are rejected)');
201
+ if (stat.uid !== currentUid())
202
+ failClosed('store file is not owned by the service user');
203
+ if (stat.mode & 0o077)
204
+ failClosed('store file must be owner-only (0600)');
205
+ }
206
+ /** Strict schema validation. Any deviation fails closed. */
207
+ function parseSnapshot(raw) {
208
+ let parsed;
209
+ try {
210
+ parsed = JSON.parse(raw);
211
+ }
212
+ catch {
213
+ failClosed('store file is not valid JSON');
214
+ }
215
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
216
+ failClosed('store file root must be an object');
217
+ }
218
+ const top = parsed;
219
+ const knownKeys = new Set(['schema', 'clients']);
220
+ for (const key of Object.keys(top)) {
221
+ if (!knownKeys.has(key))
222
+ failClosed(`unknown top-level field '${key}'`);
223
+ }
224
+ if (top.schema !== STORE_SCHEMA)
225
+ failClosed(`unsupported schema version (expected ${STORE_SCHEMA})`);
226
+ if (!Array.isArray(top.clients))
227
+ failClosed('clients must be an array');
228
+ if (top.clients.length > exports.MAX_PERSISTED_CLIENTS)
229
+ failClosed('store exceeds the bounded client limit');
230
+ const clients = new Map();
231
+ for (const entry of top.clients) {
232
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
233
+ failClosed('client entry must be an object');
234
+ const c = entry;
235
+ const allowed = new Set(['client_id', 'client_secret', 'client_secret_expires_at', 'client_name', 'redirect_uris', 'grant_types',
236
+ 'token_endpoint_auth_method', 'client_id_issued_at', 'scope', 'client_uri', 'logo_uri', 'contacts', 'response_types', 'pending_approval']);
237
+ for (const key of Object.keys(c)) {
238
+ if (!allowed.has(key))
239
+ failClosed(`client entry has unknown field '${key}'`);
240
+ }
241
+ if (typeof c.client_id !== 'string' || c.client_id.length === 0)
242
+ failClosed('client_id must be a non-empty string');
243
+ if (clients.has(c.client_id))
244
+ failClosed(`duplicate client_id in store`);
245
+ if (!Array.isArray(c.redirect_uris) || c.redirect_uris.length === 0
246
+ || c.redirect_uris.some(u => typeof u !== 'string' || u.length === 0)) {
247
+ failClosed('redirect_uris must be a non-empty array of strings');
248
+ }
249
+ if (c.client_secret !== undefined && typeof c.client_secret !== 'string')
250
+ failClosed('client_secret must be a string when present');
251
+ if (c.client_name !== undefined && typeof c.client_name !== 'string')
252
+ failClosed('client_name must be a string when present');
253
+ if (c.grant_types !== undefined && (!Array.isArray(c.grant_types) || c.grant_types.some(g => typeof g !== 'string'))) {
254
+ failClosed('grant_types must be an array of strings when present');
255
+ }
256
+ if (c.token_endpoint_auth_method !== undefined && typeof c.token_endpoint_auth_method !== 'string') {
257
+ failClosed('token_endpoint_auth_method must be a string when present');
258
+ }
259
+ if (c.client_secret_expires_at !== undefined
260
+ && (typeof c.client_secret_expires_at !== 'number' || !Number.isFinite(c.client_secret_expires_at))) {
261
+ failClosed('client_secret_expires_at must be a finite number when present');
262
+ }
263
+ // response_types: the SDK's DCR handler includes it for live clients
264
+ // (observed in the live registry written by the ChatGPT connector).
265
+ if (c.response_types !== undefined
266
+ && (!Array.isArray(c.response_types) || c.response_types.some(r => typeof r !== 'string'))) {
267
+ failClosed('response_types must be an array of strings when present');
268
+ }
269
+ if (typeof c.client_id_issued_at !== 'number' || !Number.isFinite(c.client_id_issued_at)) {
270
+ failClosed('client_id_issued_at must be a finite number');
271
+ }
272
+ if (c.pending_approval !== undefined && typeof c.pending_approval !== 'boolean') {
273
+ failClosed('pending_approval must be a boolean when present');
274
+ }
275
+ clients.set(c.client_id, entry);
276
+ }
277
+ return clients;
278
+ }
279
+ class ClientRegistry {
280
+ filePath;
281
+ clients = new Map();
282
+ lastExpiredClientSweepAt = Number.NEGATIVE_INFINITY;
283
+ constructor(filePath) {
284
+ this.filePath = filePath;
285
+ validateStoreRoot(path_1.default.dirname(filePath));
286
+ let stat = null;
287
+ try {
288
+ stat = fs_1.default.lstatSync(filePath);
289
+ }
290
+ catch (err) {
291
+ if (err.code === 'ENOENT') {
292
+ // No store yet: a fresh deployment starts with zero clients. The
293
+ // first registration creates the file; an EXISTING store that has
294
+ // become unreadable/corrupt never takes this path (it fails below).
295
+ return;
296
+ }
297
+ failClosed(`store file could not be inspected (${err.code})`);
298
+ }
299
+ if (stat === null)
300
+ failClosed('store file could not be inspected');
301
+ validateStoreFile(filePath);
302
+ let raw;
303
+ try {
304
+ raw = fs_1.default.readFileSync(filePath, 'utf-8');
305
+ }
306
+ catch (err) {
307
+ failClosed(`store file could not be read (${err.code})`);
308
+ }
309
+ this.clients = parseSnapshot(raw);
310
+ }
311
+ get size() {
312
+ this.sweepExpiredPendingClients();
313
+ return this.clients.size;
314
+ }
315
+ get(clientId) {
316
+ this.sweepExpiredPendingClients();
317
+ return this.clients.get(clientId);
318
+ }
319
+ /** Whether the client is registered but not yet approved by the owner. Stores from before approval existed have none. */
320
+ isPending(clientId) {
321
+ this.sweepExpiredPendingClients();
322
+ return this.clients.get(clientId)?.pending_approval === true;
323
+ }
324
+ /**
325
+ * True when an approved client other than `exceptClientId` already holds the hive name `identity`. A registration
326
+ * that has not been approved holds no name: anyone can register one, so only the owner's approval claims a name.
327
+ */
328
+ holdsApprovedHiveIdentity(identity, exceptClientId) {
329
+ this.sweepExpiredPendingClients();
330
+ for (const client of this.clients.values()) {
331
+ if (client.client_id !== exceptClientId && client.pending_approval !== true
332
+ && (0, sourceName_1.hiveIdentity)(client.client_name) === identity)
333
+ return true;
334
+ }
335
+ return false;
336
+ }
337
+ /**
338
+ * The hive name of this client. Two approved spellings of one header form are one agent: the earliest
339
+ * `client_id_issued_at`, then `client_id`, keeps the name, and the others resolve to no name. Nothing is deleted.
340
+ * Unapproved registrations are not holders, so one cannot win the name by registering first.
341
+ */
342
+ hiveConnectionName(clientId) {
343
+ this.sweepExpiredPendingClients();
344
+ const client = this.clients.get(clientId);
345
+ if (!client)
346
+ return undefined;
347
+ const identity = (0, sourceName_1.hiveIdentity)(client.client_name);
348
+ if (!identity)
349
+ return typeof client.client_name === 'string' ? client.client_name.trim() : undefined;
350
+ const holders = [...this.clients.values()].filter(other => other.pending_approval !== true && (0, sourceName_1.hiveIdentity)(other.client_name) === identity);
351
+ holders.sort((a, b) => {
352
+ const issued = (a.client_id_issued_at ?? 0) - (b.client_id_issued_at ?? 0);
353
+ if (issued !== 0)
354
+ return issued;
355
+ return a.client_id < b.client_id ? -1 : a.client_id > b.client_id ? 1 : 0;
356
+ });
357
+ return holders[0]?.client_id === clientId ? identity : '';
358
+ }
359
+ /**
360
+ * Disk-first registration: persists durably BEFORE the record becomes
361
+ * visible. Any I/O failure throws and leaves the registry (and disk)
362
+ * unchanged — a client is never acknowledged in memory only.
363
+ * The record is pending until approve(): it claims no hive name yet.
364
+ */
365
+ add(client) {
366
+ this.sweepExpiredPendingClients();
367
+ if (this.clients.size >= exports.MAX_PERSISTED_CLIENTS) {
368
+ throw new ClientRegistryError(`client store bound reached (${exports.MAX_PERSISTED_CLIENTS} clients)`);
369
+ }
370
+ if (this.clients.has(client.client_id)) {
371
+ throw new ClientRegistryError('client_id already exists');
372
+ }
373
+ validateStoreRoot(path_1.default.dirname(this.filePath));
374
+ const next = new Map(this.clients);
375
+ next.set(client.client_id, { ...client, pending_approval: true });
376
+ this.writeSnapshot(next);
377
+ this.clients = next;
378
+ }
379
+ /**
380
+ * The owner approved this client: it becomes a holder of its hive name. Persisted before it takes effect, like add.
381
+ */
382
+ approve(clientId) {
383
+ this.sweepExpiredPendingClients();
384
+ const client = this.clients.get(clientId);
385
+ if (!client)
386
+ throw new ClientRegistryError('client is not registered');
387
+ if (client.pending_approval !== true)
388
+ return;
389
+ validateStoreRoot(path_1.default.dirname(this.filePath));
390
+ const next = new Map(this.clients);
391
+ const { pending_approval: _pending, ...approved } = client;
392
+ next.set(clientId, approved);
393
+ this.writeSnapshot(next);
394
+ this.clients = next;
395
+ }
396
+ sweepExpiredPendingClients() {
397
+ const now = Date.now();
398
+ if (now >= this.lastExpiredClientSweepAt
399
+ && now - this.lastExpiredClientSweepAt < EXPIRED_CLIENT_SWEEP_INTERVAL_MS)
400
+ return;
401
+ this.lastExpiredClientSweepAt = now;
402
+ const next = new Map(this.clients);
403
+ for (const [clientId, client] of next) {
404
+ if (client.pending_approval === true
405
+ && (client.client_id_issued_at ?? 0) * 1000 <= now - exports.PENDING_CLIENT_APPROVAL_TTL_MS) {
406
+ next.delete(clientId);
407
+ }
408
+ }
409
+ if (next.size === this.clients.size)
410
+ return;
411
+ // Expiry only removes records nobody can use: a store that cannot be written right now must not break reads or
412
+ // sign-in, so the expired records are dropped in memory and the next write (or restart) persists the removal.
413
+ try {
414
+ validateStoreRoot(path_1.default.dirname(this.filePath));
415
+ this.writeSnapshot(next);
416
+ }
417
+ catch (err) {
418
+ if (!(err instanceof ClientRegistryError))
419
+ throw err;
420
+ }
421
+ this.clients = next;
422
+ }
423
+ writeSnapshot(clients) {
424
+ const payload = JSON.stringify({
425
+ schema: STORE_SCHEMA,
426
+ clients: Array.from(clients.values()),
427
+ });
428
+ const dir = path_1.default.dirname(this.filePath);
429
+ const tmp = path_1.default.join(dir, `.${path_1.default.basename(this.filePath)}.${process.pid}.${crypto_1.default.randomBytes(6).toString('hex')}.tmp`);
430
+ let fd;
431
+ try {
432
+ fd = fs_1.default.openSync(tmp, fs_1.default.constants.O_WRONLY | fs_1.default.constants.O_CREAT | fs_1.default.constants.O_EXCL, 0o600);
433
+ fs_1.default.writeFileSync(fd, payload, 'utf-8');
434
+ fs_1.default.fsyncSync(fd);
435
+ fs_1.default.closeSync(fd);
436
+ fd = undefined;
437
+ fs_1.default.renameSync(tmp, this.filePath);
438
+ // durable directory entry for the renamed store file
439
+ const dirFd = fs_1.default.openSync(dir, fs_1.default.constants.O_RDONLY);
440
+ try {
441
+ fs_1.default.fsyncSync(dirFd);
442
+ }
443
+ finally {
444
+ fs_1.default.closeSync(dirFd);
445
+ }
446
+ }
447
+ catch (err) {
448
+ if (fd !== undefined) {
449
+ try {
450
+ fs_1.default.closeSync(fd);
451
+ }
452
+ catch { /* best effort */ }
453
+ }
454
+ try {
455
+ fs_1.default.unlinkSync(tmp);
456
+ }
457
+ catch { /* best effort */ }
458
+ throw new ClientRegistryError(`client registration could not be persisted (${err.code ?? 'io-error'}); no client was registered`);
459
+ }
460
+ }
461
+ }
462
+ exports.ClientRegistry = ClientRegistry;