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,531 @@
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.OwnerOAuthProvider = void 0;
7
+ exports.ownerLoginRouter = ownerLoginRouter;
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ const express_1 = __importDefault(require("express"));
10
+ const errors_js_1 = require("@modelcontextprotocol/sdk/server/auth/errors.js");
11
+ const client_store_js_1 = require("./client-store.js");
12
+ const telemetry_1 = require("./telemetry");
13
+ const sourceName_1 = require("../memory/sourceName");
14
+ const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
15
+ const AUTH_TICKET_TTL_MS = 10 * 60 * 1000;
16
+ const MAX_AUTH_TICKETS = 1000; // bounded ticket store (pruned)
17
+ const MAX_REGISTERED_CLIENTS = 100; // bounded DCR store (AUTH-H2)
18
+ const MAX_REDIRECT_URIS_PER_CLIENT = 8;
19
+ const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
+ const LOCKOUT_MAX_MS = 15 * 60 * 1000;
21
+ const FAILURE_MEMORY_MS = 60 * 60 * 1000; // a failed login is forgotten after an hour without another
22
+ const TOKEN_SWEEP_EVERY = 512; // sweep expired tokens every N issues
23
+ const MAX_USED_CODES = 1000; // redeemed authorization codes remembered, to recognise a replay
24
+ function randomToken() {
25
+ return crypto_1.default.randomBytes(32).toString('base64url');
26
+ }
27
+ function htmlEscape(s) {
28
+ return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
29
+ }
30
+ function constantTimeEqual(a, b) {
31
+ const ba = Buffer.from(a, 'utf8');
32
+ const bb = Buffer.from(b, 'utf8');
33
+ if (ba.length !== bb.length)
34
+ return false;
35
+ return crypto_1.default.timingSafeEqual(ba, bb);
36
+ }
37
+ /** Metadata-only audit line: timestamp + event + opaque identifiers/counts.
38
+ * NEVER credentials, tokens, headers, query strings, or private content. */
39
+ function audit(event, fields = {}) {
40
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event, ...fields }));
41
+ }
42
+ const SECURITY_HEADERS = {
43
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
44
+ 'X-Content-Type-Options': 'nosniff',
45
+ // BROWSER-LOGIN FIX: a real Chromium submitting the consent form (a
46
+ // same-origin POST navigation) serializes the Origin header as the literal
47
+ // "null" when the page's Referrer-Policy strips the referrer (verified
48
+ // empirically with headless Chromium: Origin: null + no Referer under
49
+ // no-referrer, which the cross-origin guard must keep rejecting as an
50
+ // opaque origin). `same-origin` keeps the guard's strict-origin check
51
+ // working for real browsers: same-origin requests carry a real Origin /
52
+ // full Referer, while cross-origin requests (including the final 302 to
53
+ // the client's callback) send NO referrer — nothing about this service,
54
+ // its paths, or the authorization code leaks to the client's origin.
55
+ 'Referrer-Policy': 'same-origin',
56
+ 'Cache-Control': 'no-store',
57
+ // frame-ancestors in the CSP is the modern control; this is the same refusal for browsers that ignore it.
58
+ 'X-Frame-Options': 'DENY',
59
+ };
60
+ /** Consent-page CSP: the login form may POST only to this origin, and the
61
+ * post-submit redirect may land only on the exact allowlisted callback URIs.
62
+ * BROWSER-LOGIN FIX #2: Chromium enforces `form-action` across the redirect
63
+ * chain of a form submission (w3c/webappsec-csp#8; Nextcloud #29317,
64
+ * dotnet/aspnetcore #55491 hit the same OAuth wall). A static
65
+ * `form-action 'self'` therefore blocks the OAuth 302 from /mvp-login to the
66
+ * client's callback, breaking every legitimate browser login. Allowing the
67
+ * same exact URIs the DCR allowlist already governs keeps this strict: the
68
+ * form still cannot POST anywhere but this service, and redirects still
69
+ * cannot land anywhere but an owner-approved callback. */
70
+ function consentPageCsp(allowedCallbackUris) {
71
+ const sources = ["'self'"];
72
+ for (const uri of allowedCallbackUris) {
73
+ try {
74
+ const u = new URL(uri);
75
+ sources.push(u.origin + (u.pathname === '/' ? '' : u.pathname));
76
+ }
77
+ catch {
78
+ // config validation already rejects invalid allowlist entries
79
+ }
80
+ }
81
+ return `default-src 'none'; style-src 'unsafe-inline'; form-action ${sources.join(' ')}; frame-ancestors 'none'`;
82
+ }
83
+ class OwnerOAuthProvider {
84
+ // CLIENT-PERSISTENCE: registered OAuth client metadata survives service
85
+ // restarts in the private owner-only store; anything else about the store
86
+ // being unreadable/corrupt FAILS CLOSED here (startup error), never a
87
+ // silent reset to empty. Authorization tickets, codes, and tokens are
88
+ // deliberately NOT persisted: they stay short-lived and single-use.
89
+ clients;
90
+ authorizationCodes = new Map();
91
+ accessTokens = new Map();
92
+ refreshTokens = new Map();
93
+ authTickets = new Map();
94
+ usedCodes = new Map();
95
+ config;
96
+ // Global single-owner login limiter (AUTH-B3): one owner secret => one
97
+ // global failure counter; peer/client identifiers are not rate-limit keys
98
+ // (they are unauthenticated input and spoofable).
99
+ loginFailures = 0;
100
+ loginLockedUntil = 0;
101
+ lastFailureAt = 0;
102
+ issuedSinceSweep = 0;
103
+ constructor(config) {
104
+ this.config = config;
105
+ this.clients = new client_store_js_1.ClientRegistry(config.authStatePath);
106
+ }
107
+ /** Header-form hive name for this OAuth client. A legacy duplicate spelling resolves to no name. */
108
+ hiveConnectionName(clientId) {
109
+ return this.clients.hiveConnectionName(clientId);
110
+ }
111
+ get clientsStore() {
112
+ return {
113
+ getClient: (clientId) => this.clients.get(clientId),
114
+ // DCR policy (AUTH-B1): DENY BY DEFAULT. A registration is accepted
115
+ // only when the owner configured an exact-callback-URI allowlist
116
+ // (MVP_ALLOWED_CALLBACK_URIS) and EVERY proposed redirect_uri matches
117
+ // one allowlisted entry exactly. No wildcards, no guessed domains,
118
+ // no http(s) heuristic substitution — the owner names the callbacks.
119
+ registerClient: (client) => {
120
+ const allow = this.config.allowedCallbackUris;
121
+ const uris = client.redirect_uris ?? [];
122
+ if (!Array.isArray(uris) || uris.length === 0) {
123
+ audit('dcr_denied', { reason: 'no-redirect-uris' });
124
+ throw new errors_js_1.InvalidClientMetadataError('DCR denied: registration must propose at least one redirect_uri');
125
+ }
126
+ if (uris.length > MAX_REDIRECT_URIS_PER_CLIENT) {
127
+ audit('dcr_denied', { reason: 'too-many-redirect-uris' });
128
+ throw new errors_js_1.InvalidClientMetadataError(`DCR denied: at most ${MAX_REDIRECT_URIS_PER_CLIENT} redirect_uris per client`);
129
+ }
130
+ if (allow.length === 0) {
131
+ audit('dcr_denied', { reason: 'deny-by-default-no-allowlist' });
132
+ throw new errors_js_1.InvalidClientMetadataError('DCR denied by default: the owner has not configured an exact callback-URI allowlist '
133
+ + '(MVP_ALLOWED_CALLBACK_URIS). No client may register until the owner explicitly allows its callback URIs.');
134
+ }
135
+ for (const uri of uris) {
136
+ if (typeof uri !== 'string' || !allow.includes(uri)) {
137
+ audit('dcr_denied', { reason: 'redirect-uri-not-allowlisted' });
138
+ throw new errors_js_1.InvalidClientMetadataError('DCR denied: redirect_uri is not in the owner allowlist '
139
+ + '(MVP_ALLOWED_CALLBACK_URIS allows only exact configured callback URIs)');
140
+ }
141
+ }
142
+ if (this.clients.size >= MAX_REGISTERED_CLIENTS) {
143
+ audit('dcr_denied', { reason: 'client-store-bound' });
144
+ throw new errors_js_1.InvalidClientMetadataError(`DCR denied: client store bound reached (${MAX_REGISTERED_CLIENTS} clients)`);
145
+ }
146
+ const client_id = randomToken();
147
+ const full = {
148
+ ...client,
149
+ client_id,
150
+ client_id_issued_at: Math.floor(Date.now() / 1000),
151
+ };
152
+ // CLIENT-PERSISTENCE: the registration is durably written (atomic
153
+ // fsync + rename) BEFORE it is acknowledged. If the write fails the
154
+ // client must NOT exist — not even in memory — so a restart can
155
+ // never break a client that was promised to its owner. Mutations
156
+ // are synchronous, serializing all concurrent registrations in
157
+ // this single process.
158
+ try {
159
+ this.clients.add(full);
160
+ }
161
+ catch (err) {
162
+ audit('dcr_denied', { reason: 'persist-failed' });
163
+ throw new errors_js_1.ServerError(`DCR failed: ${err.message} — no client was registered; `
164
+ + 'the client store must be writable and healthy before registration succeeds');
165
+ }
166
+ audit('dcr_ok', { client_id: client_id.slice(0, 8) });
167
+ return full;
168
+ },
169
+ };
170
+ }
171
+ pruneTickets() {
172
+ const now = Date.now();
173
+ if (this.authTickets.size < MAX_AUTH_TICKETS)
174
+ return;
175
+ for (const [id, ticket] of this.authTickets) {
176
+ if (ticket.expiresAt < now || ticket.used)
177
+ this.authTickets.delete(id);
178
+ }
179
+ if (this.authTickets.size >= MAX_AUTH_TICKETS) {
180
+ // still full: drop the oldest entries (bounded store, fail closed)
181
+ const excess = this.authTickets.size - MAX_AUTH_TICKETS + 1;
182
+ const ids = Array.from(this.authTickets.keys()).slice(0, excess);
183
+ for (const id of ids)
184
+ this.authTickets.delete(id);
185
+ }
186
+ }
187
+ /**
188
+ * Owner consent + login. Called by the SDK's /authorize AFTER it validated
189
+ * the client and the redirect_uri against the client's registered list.
190
+ * Mints a one-time authorization-request ticket bound to the EXACT request
191
+ * parameters and renders the consent page. The login POST must present the
192
+ * ticket; all OAuth parameters are taken from the ticket server-side.
193
+ */
194
+ async authorize(client, params, res) {
195
+ // Restart-policy enforcement (CLIENT-PERSISTENCE): a client persisted in
196
+ // a previous run may only authorize while its EXACT callback URI is in
197
+ // the owner's CURRENT allowlist. Removing a URI from
198
+ // MVP_ALLOWED_CALLBACK_URIS and restarting therefore revokes fresh
199
+ // authorizations for persisted clients without deleting their records.
200
+ if (!this.config.allowedCallbackUris.includes(params.redirectUri)) {
201
+ audit('authorize_denied', { reason: 'callback-not-allowlisted' });
202
+ for (const [key, value] of Object.entries(SECURITY_HEADERS))
203
+ res.setHeader(key, value);
204
+ res.status(403).send('Authorization denied: this callback URI is no longer allowlisted by the owner.').end();
205
+ return;
206
+ }
207
+ const scopes = params.scopes?.length ? params.scopes : ['memory'];
208
+ this.pruneTickets();
209
+ const ticket = randomToken();
210
+ this.authTickets.set(ticket, {
211
+ clientId: client.client_id,
212
+ redirectUri: params.redirectUri,
213
+ challenge: params.codeChallenge,
214
+ scopes,
215
+ resource: params.resource,
216
+ state: params.state,
217
+ expiresAt: Date.now() + AUTH_TICKET_TTL_MS,
218
+ used: false,
219
+ });
220
+ // Visible, honest consent (AUTH-B1): escaped client name, the EXACT
221
+ // redirect destination (origin + full callback URI), the scope, an
222
+ // explicit unreviewed-client warning, and an explicit approval checkbox.
223
+ let redirectOrigin = '(unknown origin)';
224
+ try {
225
+ redirectOrigin = new URL(params.redirectUri).origin;
226
+ }
227
+ catch {
228
+ // keep the placeholder; the ticket still binds the exact string
229
+ }
230
+ for (const [key, value] of Object.entries(SECURITY_HEADERS))
231
+ res.setHeader(key, value);
232
+ res.setHeader('Content-Security-Policy', consentPageCsp(this.config.allowedCallbackUris));
233
+ res.setHeader('Content-Type', 'text/html; charset=utf-8').send(`<!doctype html>
234
+ <html><head><title>Memory Company — owner authorization</title></head>
235
+ <body style="font-family: sans-serif; max-width: 32rem; margin: 4rem auto;">
236
+ <h2>Memory Company (single-owner MVP)</h2>
237
+ <p>Application <b>${htmlEscape(client.client_name ?? client.client_id)}</b> is requesting <b>${htmlEscape(scopes.join(' '))}</b> access to your memory service.</p>
238
+ <p><b>Redirect destination:</b> the authorization code will be sent to<br>
239
+ <b>${htmlEscape(redirectOrigin)}</b><br>
240
+ <code>${htmlEscape(params.redirectUri)}</code></p>
241
+ <p><b style="color:#b00">Warning:</b> this client is <b>unreviewed</b>. Anyone can attempt to register a client. Authorizing grants full read/write access to your private memory (facts and history). Only approve clients you trust and whose callback destination above you recognize.</p>
242
+ <form method="post" action="/mvp-login">
243
+ <input type="hidden" name="ticket" value="${htmlEscape(ticket)}">
244
+ <p><label><input type="checkbox" name="approve" value="on" required> I approve this application and the redirect destination shown above</label></p>
245
+ <p><label>Email<br><input type="email" name="email" autocomplete="username" required style="width:100%"></label></p>
246
+ <p><label>Secret<br><input type="password" name="password" autocomplete="current-password" required style="width:100%"></label></p>
247
+ <p><button type="submit">Authorize</button></p>
248
+ </form>
249
+ </body></html>`);
250
+ }
251
+ /**
252
+ * Called by the server's /mvp-login route after the owner submits the
253
+ * consent form. The one-time ticket supplies every OAuth parameter; the
254
+ * POST body may only add credentials and the explicit approval.
255
+ */
256
+ handleOwnerLogin(ticketId, email, password, approved) {
257
+ // Global single-owner limiter first (AUTH-B3): while locked out, even
258
+ // correct credentials are refused; the response stays metadata-only.
259
+ const now = Date.now();
260
+ if (now < this.loginLockedUntil) {
261
+ audit('login_locked', { retry_after_ms: this.loginLockedUntil - now });
262
+ return { ok: false, error: 'Too many failed login attempts. Try again later.', status: 429, retryAfterSeconds: Math.ceil((this.loginLockedUntil - now) / 1000) };
263
+ }
264
+ const ticket = ticketId ? this.authTickets.get(ticketId) : undefined;
265
+ // Consume the ticket on FIRST use attempt (single-use, anti-replay):
266
+ // an unknown/expired/used ticket can never mint a code.
267
+ if (ticket)
268
+ this.authTickets.delete(ticketId);
269
+ if (!ticket || ticket.used || ticket.expiresAt < now) {
270
+ audit('login_ticket_rejected', { reason: !ticket ? 'unknown' : ticket.used ? 'replayed' : 'expired' });
271
+ return { ok: false, error: 'Invalid or expired authorization request. Restart the authorization from the application.', status: 403 };
272
+ }
273
+ if (!approved) {
274
+ audit('login_rejected', { reason: 'not-approved' });
275
+ return { ok: false, error: 'Authorization was not approved.', status: 403 };
276
+ }
277
+ // Defense in depth: re-validate the client and the redirect membership
278
+ // from the SERVER-SIDE ticket against the registered client record.
279
+ const client = this.clients.get(ticket.clientId);
280
+ if (!client) {
281
+ audit('login_rejected', { reason: 'unknown-client' });
282
+ return { ok: false, error: 'Unknown client.', status: 403 };
283
+ }
284
+ if (!(client.redirect_uris ?? []).includes(ticket.redirectUri)) {
285
+ audit('login_rejected', { reason: 'redirect-uri-not-registered' });
286
+ return { ok: false, error: 'Redirect destination is not registered for this client.', status: 403 };
287
+ }
288
+ const emailOk = constantTimeEqual(email.trim().toLowerCase(), this.config.ownerEmail);
289
+ const passwordOk = constantTimeEqual(password, this.config.ownerPassword);
290
+ if (!emailOk || !passwordOk) {
291
+ if (now - this.lastFailureAt > FAILURE_MEMORY_MS)
292
+ this.loginFailures = 0;
293
+ this.lastFailureAt = now;
294
+ this.loginFailures += 1;
295
+ const max = this.config.loginMaxFailures;
296
+ if (this.loginFailures >= max) {
297
+ const exponent = Math.min(this.loginFailures - max, 8);
298
+ this.loginLockedUntil = now + Math.min(this.config.loginLockoutMs * Math.pow(2, exponent), LOCKOUT_MAX_MS);
299
+ (0, telemetry_1.emitTelemetry)({ event: 'auth', kind: 'login_fail' });
300
+ audit('login_failed', { failures: this.loginFailures, locked: true });
301
+ return { ok: false, error: 'Owner credentials rejected. Too many failed attempts; login is temporarily locked.', status: 429, retryAfterSeconds: Math.ceil((this.loginLockedUntil - now) / 1000) };
302
+ }
303
+ (0, telemetry_1.emitTelemetry)({ event: 'auth', kind: 'login_fail' });
304
+ audit('login_failed', { failures: this.loginFailures });
305
+ return { ok: false, error: 'Owner credentials rejected. Only the configured owner can authorize this service.', status: 403 };
306
+ }
307
+ // Approval claims the client's hive name, so it is refused while an approved client holds that name, and it is
308
+ // recorded before the code is minted: a write that fails mints nothing.
309
+ const identity = (0, sourceName_1.hiveIdentity)(client.client_name);
310
+ if (identity && this.clients.holdsApprovedHiveIdentity(identity, client.client_id)) {
311
+ audit('login_rejected', { reason: 'identity-held' });
312
+ return {
313
+ ok: false,
314
+ error: `Another approved client already uses the name ${identity}. Give this client a different client_name, or reconnect the client that holds that name.`,
315
+ status: 409,
316
+ };
317
+ }
318
+ try {
319
+ this.clients.approve(client.client_id);
320
+ }
321
+ catch {
322
+ audit('login_rejected', { reason: 'approval-persist-failed' });
323
+ return { ok: false, error: 'Could not record the approval; nothing was authorized. Retry once the client store is writable.', status: 500 };
324
+ }
325
+ // Success resets the limiter and mints a code bound to the ticket values.
326
+ this.loginFailures = 0;
327
+ this.loginLockedUntil = 0;
328
+ const code = randomToken();
329
+ this.authorizationCodes.set(code, {
330
+ clientId: ticket.clientId,
331
+ redirectUri: ticket.redirectUri,
332
+ challenge: ticket.challenge,
333
+ scopes: ticket.scopes,
334
+ resource: ticket.resource,
335
+ expiresAt: now + AUTH_CODE_TTL_MS,
336
+ });
337
+ const redirect = new URL(ticket.redirectUri);
338
+ redirect.searchParams.set('code', code);
339
+ (0, telemetry_1.emitTelemetry)({ event: 'auth', kind: 'login_ok' });
340
+ audit('login_ok', { client_id: ticket.clientId.slice(0, 8) });
341
+ return { ok: true, redirect, state: ticket.state };
342
+ }
343
+ async challengeForAuthorizationCode(client, authorizationCode) {
344
+ const stored = this.authorizationCodes.get(authorizationCode);
345
+ if (!stored)
346
+ return this.refuseUnknownCode(client, authorizationCode);
347
+ if (stored.clientId !== client.client_id)
348
+ throw new errors_js_1.InvalidGrantError('Authorization code was issued to a different client');
349
+ return stored.challenge;
350
+ }
351
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
352
+ const stored = this.authorizationCodes.get(authorizationCode);
353
+ if (!stored)
354
+ return this.refuseUnknownCode(client, authorizationCode);
355
+ if (stored.expiresAt < Date.now()) {
356
+ throw new errors_js_1.InvalidGrantError('Authorization code not found or expired');
357
+ }
358
+ if (stored.clientId !== client.client_id) {
359
+ throw new errors_js_1.InvalidGrantError('Authorization code was issued to a different client');
360
+ }
361
+ if (redirectUri && stored.redirectUri !== redirectUri) {
362
+ throw new errors_js_1.InvalidGrantError('redirect_uri does not match the authorization request');
363
+ }
364
+ this.authorizationCodes.delete(authorizationCode);
365
+ const { tokens, family } = this.issue(client.client_id, stored.scopes, resource ?? stored.resource);
366
+ this.usedCodes.set(authorizationCode, family);
367
+ if (this.usedCodes.size > MAX_USED_CODES)
368
+ this.usedCodes.delete(this.usedCodes.keys().next().value);
369
+ return tokens;
370
+ }
371
+ /** A code that is not on file: if it was already redeemed, whoever redeemed it first holds tokens that must not
372
+ * survive the replay (RFC 6749 4.1.2). Always `invalid_grant` (400), never a 500 the client would retry. */
373
+ refuseUnknownCode(client, authorizationCode) {
374
+ const family = this.usedCodes.get(authorizationCode);
375
+ if (family && family.clientId === client.client_id)
376
+ this.killFamily(family);
377
+ throw new errors_js_1.InvalidGrantError('Authorization code not found or expired');
378
+ }
379
+ async exchangeRefreshToken(client, refreshToken, _scopes, resource) {
380
+ const stored = this.refreshTokens.get(refreshToken);
381
+ if (!stored || stored.clientId !== client.client_id) {
382
+ throw new errors_js_1.InvalidGrantError('Refresh token not found or was issued to a different client');
383
+ }
384
+ // AUTH-H1: refresh tokens expire — a stolen refresh token dies with the
385
+ // bound lifetime, not with the process.
386
+ if (stored.refreshExpiresAt < Date.now()) {
387
+ this.refreshTokens.delete(refreshToken);
388
+ throw new errors_js_1.InvalidGrantError('Refresh token expired');
389
+ }
390
+ // Rotate: single-use refresh tokens, and the access token minted beside the old one dies with it.
391
+ this.refreshTokens.delete(refreshToken);
392
+ this.accessTokens.delete(stored.family.access);
393
+ return this.issue(client.client_id, stored.scopes, resource, stored.family).tokens;
394
+ }
395
+ async verifyAccessToken(token) {
396
+ const stored = this.accessTokens.get(token);
397
+ if (!stored)
398
+ throw new Error('Token invalid or revoked');
399
+ if (stored.expiresAt < Date.now()) {
400
+ this.accessTokens.delete(token);
401
+ throw new Error('Token expired');
402
+ }
403
+ return {
404
+ token,
405
+ clientId: stored.clientId,
406
+ scopes: stored.scopes,
407
+ expiresAt: Math.floor(stored.expiresAt / 1000),
408
+ resource: undefined,
409
+ };
410
+ }
411
+ async revokeToken(client, request) {
412
+ // Invalid or already-revoked tokens: do nothing, per interface contract. Only the client a token was issued
413
+ // to may revoke it, and revoking either half ends the pair (RFC 7009 2.1).
414
+ const stored = this.accessTokens.get(request.token) ?? this.refreshTokens.get(request.token);
415
+ if (stored && stored.clientId === client.client_id)
416
+ this.killFamily(stored.family);
417
+ }
418
+ killFamily(family) {
419
+ this.accessTokens.delete(family.access);
420
+ this.refreshTokens.delete(family.refresh);
421
+ }
422
+ /**
423
+ * Mint a short-lived access token for the trusted universal gateway.
424
+ * Disabled unless gatewaySecret is configured.
425
+ */
426
+ issueGatewayAccessToken(presentedSecret) {
427
+ const expected = this.config.gatewaySecret;
428
+ if (!expected || !presentedSecret || !constantTimeEqual(expected, presentedSecret)) {
429
+ throw new Error('gateway unauthorized');
430
+ }
431
+ return this.issue('renor-gateway', ['mcp'], undefined).tokens;
432
+ }
433
+ /** Expired tokens used to leave only when presented; sweep them so a long-running process does not grow without bound. */
434
+ sweepTokens() {
435
+ const now = Date.now();
436
+ for (const [t, s] of this.accessTokens)
437
+ if (s.expiresAt < now)
438
+ this.accessTokens.delete(t);
439
+ for (const [t, s] of this.refreshTokens)
440
+ if (s.refreshExpiresAt < now)
441
+ this.refreshTokens.delete(t);
442
+ }
443
+ issue(clientId, scopes, resource, chain) {
444
+ if (++this.issuedSinceSweep >= TOKEN_SWEEP_EVERY) {
445
+ this.issuedSinceSweep = 0;
446
+ this.sweepTokens();
447
+ }
448
+ const accessToken = randomToken();
449
+ const refreshToken = randomToken();
450
+ const expiresAt = Date.now() + this.config.accessTokenTtlSeconds * 1000;
451
+ const family = chain ?? { clientId, access: accessToken, refresh: refreshToken };
452
+ family.access = accessToken;
453
+ family.refresh = refreshToken;
454
+ const stored = {
455
+ family,
456
+ clientId,
457
+ scopes,
458
+ expiresAt,
459
+ refreshExpiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
460
+ };
461
+ this.accessTokens.set(accessToken, stored);
462
+ this.refreshTokens.set(refreshToken, stored);
463
+ const tokens = {
464
+ access_token: accessToken,
465
+ token_type: 'Bearer',
466
+ expires_in: this.config.accessTokenTtlSeconds,
467
+ refresh_token: refreshToken,
468
+ scope: scopes.join(' ') || 'memory',
469
+ resource: resource?.href,
470
+ };
471
+ return { tokens, family };
472
+ }
473
+ /** Test/operations helper: limiter is bounded and recoverable by design. */
474
+ limiterState() {
475
+ return { failures: this.loginFailures, lockedUntil: this.loginLockedUntil };
476
+ }
477
+ }
478
+ exports.OwnerOAuthProvider = OwnerOAuthProvider;
479
+ /** Express router for the owner consent+login form submit (mounted at /mvp-login). */
480
+ function ownerLoginRouter(provider, config) {
481
+ const router = express_1.default.Router();
482
+ router.post('/', express_1.default.urlencoded({ extended: false }), (req, res) => {
483
+ const { ticket, email, password, approve } = (req.body ?? {});
484
+ // Security headers apply to EVERY outcome of this handler (including
485
+ // the 403/400/429 rejections below), so no response from the login
486
+ // endpoint ever renders with looser referrer/CSP/frame semantics than
487
+ // the consent page that linked to it.
488
+ for (const [key, value] of Object.entries(SECURITY_HEADERS))
489
+ res.setHeader(key, value);
490
+ // Cross-origin POST protection (AUTH-B1): when a browser supplies
491
+ // Origin or Referer, it must be this service's own origin. Spoofable
492
+ // forwarding headers (X-Forwarded-*) are deliberately NOT trusted.
493
+ const origin = req.headers.origin ?? req.headers.referer;
494
+ if (origin) {
495
+ let allowed = false;
496
+ try {
497
+ const supplied = new URL(origin);
498
+ allowed = supplied.origin === config.baseUrl.origin;
499
+ }
500
+ catch {
501
+ allowed = false;
502
+ }
503
+ if (!allowed) {
504
+ audit('login_rejected', { reason: 'cross-origin-post' });
505
+ res.status(403).send('Cross-origin login requests are not accepted.').end();
506
+ return;
507
+ }
508
+ }
509
+ // A repeated field (email=a&email=b) parses to an array: refuse it before the ticket is spent.
510
+ if ([ticket, email, password, approve].some(v => v !== undefined && typeof v !== 'string')) {
511
+ res.status(400).send('Invalid authorization request.').end();
512
+ return;
513
+ }
514
+ if (!ticket) {
515
+ res.status(400).send('Invalid authorization request: missing one-time ticket. Restart the authorization from the application.').end();
516
+ return;
517
+ }
518
+ const result = provider.handleOwnerLogin(ticket, email ?? '', password ?? '', approve === 'on');
519
+ if (!result.ok) {
520
+ // Privacy-safe: no detail beyond the rejection, no code, no redirect.
521
+ if (result.status === 429)
522
+ res.setHeader('Retry-After', String(result.retryAfterSeconds ?? 1));
523
+ res.status(result.status).send(htmlEscape(result.error)).end();
524
+ return;
525
+ }
526
+ if (result.state)
527
+ result.redirect.searchParams.set('state', result.state);
528
+ res.redirect(302, result.redirect.href);
529
+ });
530
+ return router;
531
+ }
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // FIRST, and for its side effect: enabling this workspace's Node compile cache
4
+ // before anything heavy is resolved. With every workspace asleep after its idle
5
+ // period, the gateway's wake pays the whole module-load phase again, and the
6
+ // compile cache is what keeps that phase off the critical path. It must come
7
+ // before ./server, which transitively pulls the o200k_base table (~300 ms of a
8
+ // ~930 ms module-load phase, measured on Node 22 against an empty workspace).
9
+ const compileCache_1 = require("./compileCache");
10
+ (0, compileCache_1.enableWorkspaceCompileCache)();
11
+ const config_1 = require("./config");
12
+ const server_1 = require("./server");
13
+ const telemetry_1 = require("./telemetry");
14
+ const posthogMcp_1 = require("./posthogMcp");
15
+ /**
16
+ * CLI entry for the single-user ChatGPT MVP service.
17
+ *
18
+ * Required environment:
19
+ * MVP_MEMORY_DIR dedicated owner memory directory (create a NEW folder)
20
+ * MVP_BASE_URL externally reachable base URL (http://localhost:<port> for
21
+ * local protocol tests; your public https URL for ChatGPT)
22
+ * MVP_OWNER_EMAIL the single owner login email
23
+ * MVP_OWNER_PASSWORD owner login secret (env-only, never in Git)
24
+ * Optional:
25
+ * MVP_PORT (default 3471), MVP_LEDGER_REL, MVP_ACCESS_TOKEN_TTL_SECONDS
26
+ */
27
+ async function main() {
28
+ const config = (0, config_1.loadMvpConfig)();
29
+ const mvp = await (0, server_1.startMvpService)(config);
30
+ console.log(`[mvp] Renor single-owner memory service`);
31
+ console.log(`[mvp] Memory directory : ${config.memoryDir}`);
32
+ console.log(`[mvp] Ledger (source) : ${config.ledgerRelPath}`);
33
+ console.log(`[mvp] Derived index : ${config.dbPath} (rebuildable from the ledger)`);
34
+ console.log(`[mvp] Listening : http://127.0.0.1:${config.port}`);
35
+ console.log(`[mvp] MCP endpoint : ${new URL(server_1.MVP_MCP_PATH, config.baseUrl).href}`);
36
+ console.log(`[mvp] OAuth discovery : ${new URL('/.well-known/oauth-authorization-server', config.baseUrl).href}`);
37
+ console.log(`[mvp] Owner : ${config.ownerEmail} (the only identity that can authorize)`);
38
+ if (config.historyDir) {
39
+ console.log(`[mvp] History directory : ${config.historyDir} (search_history: promoted ChatGPT history, manifest-verified)`);
40
+ }
41
+ else {
42
+ console.log(`[mvp] History directory : not configured (search_history returns a bounded unconfigured result; set MVP_HISTORY_DIR to enable)`);
43
+ }
44
+ console.log(`[mvp] Local proof : run the MCP client tests in tests/mvp/`);
45
+ console.log(`[mvp] DCR allowlist : ${config.allowedCallbackUris.length} exact callback URI(s)${config.allowedCallbackUris.length ? '' : ' — DENY BY DEFAULT: no client may register until MVP_ALLOWED_CALLBACK_URIS names exact callbacks'}`);
46
+ console.log(`[mvp] Exposure boundary : loopback-bound; any public use REQUIRES TLS termination in front (no public mode is shipped).`);
47
+ console.log(`[mvp] Embeddings : ${config.embeddingProvider}${config.embeddingDims ? ` (${config.embeddingDims}-d)` : ''}`);
48
+ const shutdown = async () => {
49
+ try {
50
+ await mvp.close();
51
+ }
52
+ finally {
53
+ await Promise.allSettled([(0, telemetry_1.flushTelemetry)(), (0, posthogMcp_1.shutdownPosthogMcp)()]);
54
+ }
55
+ process.exit(0);
56
+ };
57
+ process.on('SIGINT', shutdown);
58
+ process.on('SIGTERM', shutdown);
59
+ if (mvp.idleExit) {
60
+ console.log(`[mvp] On-demand : exits after ${config.idleExitMs / 1000}s with no work in flight (RENOR_ON_DEMAND); the gateway starts it again`);
61
+ void mvp.idleExit.then(() => {
62
+ console.log('[mvp] idle: no work in flight for the idle period; exiting cleanly');
63
+ // Exit 0 is what leaves the unit stopped (Restart=on-failure). Nothing is in
64
+ // flight, so a telemetry flush that hangs must not keep the engine resident.
65
+ setTimeout(() => process.exit(0), 15_000).unref();
66
+ return shutdown();
67
+ });
68
+ }
69
+ }
70
+ main().catch((err) => {
71
+ console.error('[mvp] startup failed:', err instanceof Error ? err.message : err);
72
+ process.exit(1);
73
+ });