@remnic/core 9.3.768 → 9.3.770

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 (162) hide show
  1. package/dist/access-admin-ops-surface.js +29 -29
  2. package/dist/access-boundary.d.ts +2 -1
  3. package/dist/access-boundary.js +30 -30
  4. package/dist/access-cli.js +85 -83
  5. package/dist/access-cli.js.map +1 -1
  6. package/dist/access-errors.d.ts +5 -0
  7. package/dist/access-errors.js +8 -0
  8. package/dist/access-errors.js.map +1 -0
  9. package/dist/access-http.d.ts +51 -1
  10. package/dist/access-http.js +38 -36
  11. package/dist/access-identity-continuity-surface.js +29 -29
  12. package/dist/access-lcm-surface.js +29 -29
  13. package/dist/access-mcp.d.ts +7 -1
  14. package/dist/access-mcp.js +41 -37
  15. package/dist/access-observe-write-surface.js +29 -29
  16. package/dist/access-operations-batch.js +34 -32
  17. package/dist/access-operations.d.ts +4 -4
  18. package/dist/access-operations.js +36 -34
  19. package/dist/access-recall-surface.js +29 -29
  20. package/dist/access-schema.d.ts +68 -68
  21. package/dist/access-service.js +29 -29
  22. package/dist/access-surface-catalog.js +1 -0
  23. package/dist/access-surface-catalog.js.map +1 -1
  24. package/dist/active-recall.js +5 -5
  25. package/dist/auto-sync-2SOUW4JW.js +1 -1
  26. package/dist/briefing.js +7 -7
  27. package/dist/causal-behavior.js +5 -5
  28. package/dist/causal-chain.js +3 -3
  29. package/dist/causal-consolidation.js +11 -11
  30. package/dist/causal-retrieval.js +3 -3
  31. package/dist/causal-trajectory.js +1 -1
  32. package/dist/{chunk-R3K4CXPR.js → chunk-3JOFWTQD.js} +2 -2
  33. package/dist/{chunk-QOGXUNWL.js → chunk-3O3BZNSH.js} +4 -4
  34. package/dist/{chunk-SFNQDXXL.js → chunk-3YYWUHA7.js} +3 -3
  35. package/dist/{chunk-GLERZDM7.js → chunk-52TEPNL7.js} +29 -7
  36. package/dist/chunk-52TEPNL7.js.map +1 -0
  37. package/dist/{chunk-N4ZA2I7M.js → chunk-5ACKQLGA.js} +2 -2
  38. package/dist/{chunk-YDYC6PIS.js → chunk-5QJZLJ7Q.js} +2 -2
  39. package/dist/{chunk-ORAK3LNZ.js → chunk-6SHAUM5S.js} +5 -5
  40. package/dist/{chunk-CKNAVW34.js → chunk-7OSULYN2.js} +2 -2
  41. package/dist/{chunk-PA5ALD3S.js → chunk-7XGF5HCC.js} +106 -86
  42. package/dist/chunk-7XGF5HCC.js.map +1 -0
  43. package/dist/{chunk-6T4LTI2F.js → chunk-7XH7VJN4.js} +4 -4
  44. package/dist/{chunk-OX272PBW.js → chunk-CSHUSYUM.js} +2 -2
  45. package/dist/{chunk-UG274TNV.js → chunk-E2SPGGUI.js} +13 -8
  46. package/dist/chunk-E2SPGGUI.js.map +1 -0
  47. package/dist/{chunk-HBOPSFQQ.js → chunk-E6LXM2GE.js} +41 -41
  48. package/dist/{chunk-OAREUC7N.js → chunk-EWGMA5W5.js} +28 -28
  49. package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
  50. package/dist/chunk-FTYDWKO6.js +101 -0
  51. package/dist/chunk-FTYDWKO6.js.map +1 -0
  52. package/dist/{chunk-2RLECZ6J.js → chunk-IOENSSZC.js} +2 -2
  53. package/dist/{chunk-VKHZFF5P.js → chunk-KFIAFHOC.js} +2 -2
  54. package/dist/{chunk-NDXJJKT2.js → chunk-KMKBD66B.js} +2 -2
  55. package/dist/{chunk-SQNED75R.js → chunk-KYC3RLYJ.js} +1 -1
  56. package/dist/{chunk-V5RVMULT.js → chunk-MLDAD27I.js} +7 -7
  57. package/dist/{chunk-IGVKWKR2.js → chunk-O5EKTMOJ.js} +6 -6
  58. package/dist/{chunk-TVLN5EZZ.js → chunk-OP43P53G.js} +83 -8
  59. package/dist/chunk-OP43P53G.js.map +1 -0
  60. package/dist/{chunk-4HYFKIGH.js → chunk-PSNMICC2.js} +4 -4
  61. package/dist/{chunk-IVFIYAIP.js → chunk-QKUWPSU3.js} +5 -5
  62. package/dist/{chunk-4DLFJJOQ.js → chunk-QMN3CIFS.js} +2 -2
  63. package/dist/chunk-RANHFI3P.js +8 -0
  64. package/dist/chunk-RANHFI3P.js.map +1 -0
  65. package/dist/{chunk-AS2NRB45.js → chunk-RZ3KJZYE.js} +2 -2
  66. package/dist/{chunk-4JG55YV3.js → chunk-SRSPVFSJ.js} +3 -3
  67. package/dist/{chunk-DTTVX6EO.js → chunk-STN5KMSS.js} +12 -12
  68. package/dist/{chunk-J3UJJZKI.js → chunk-TGROC2UP.js} +108 -7
  69. package/dist/chunk-TGROC2UP.js.map +1 -0
  70. package/dist/{chunk-QPTP6SKC.js → chunk-U72WWTLR.js} +2 -2
  71. package/dist/{chunk-U5GFYMV7.js → chunk-W7VA5667.js} +3 -3
  72. package/dist/{chunk-X7HTU4HO.js → chunk-WB4CFKZ4.js} +2 -2
  73. package/dist/{chunk-NFWTMM2M.js → chunk-XDRJQMYL.js} +2 -2
  74. package/dist/{chunk-CVINPTKG.js → chunk-ZG4FT6IT.js} +5 -5
  75. package/dist/{chunk-MSGPLITO.js → chunk-ZT34X56N.js} +2 -2
  76. package/dist/{chunk-MSGPLITO.js.map → chunk-ZT34X56N.js.map} +1 -1
  77. package/dist/cli.js +58 -56
  78. package/dist/compounding/engine.js +7 -7
  79. package/dist/config.js +2 -2
  80. package/dist/connectors/codex-materialize-runner.js +7 -7
  81. package/dist/connectors/index.js +9 -9
  82. package/dist/consolidation-provenance-check.js +2 -2
  83. package/dist/dashboard-runtime.js +2 -2
  84. package/dist/entity-retrieval.js +7 -7
  85. package/dist/extraction.js +3 -3
  86. package/dist/index.d.ts +1 -1
  87. package/dist/index.js +141 -133
  88. package/dist/lcm/engine.js +3 -3
  89. package/dist/lcm/index.js +11 -11
  90. package/dist/maintenance/memory-governance.js +8 -8
  91. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  92. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  93. package/dist/namespaces/migrate.js +12 -12
  94. package/dist/namespaces/search.js +4 -4
  95. package/dist/namespaces/storage.js +7 -7
  96. package/dist/operator-toolkit.js +17 -17
  97. package/dist/orchestration/maintenance.js +9 -9
  98. package/dist/orchestrator.js +85 -83
  99. package/dist/recall-timings.d.ts +26 -0
  100. package/dist/recall-timings.js +16 -0
  101. package/dist/recall-timings.js.map +1 -0
  102. package/dist/resume-bundles.js +3 -3
  103. package/dist/schemas.d.ts +84 -84
  104. package/dist/search/factory.js +3 -3
  105. package/dist/search/index.js +7 -7
  106. package/dist/semantic-consolidation.js +10 -10
  107. package/dist/semantic-rule-promotion.js +7 -7
  108. package/dist/semantic-rule-verifier.js +8 -8
  109. package/dist/shared-context/manager.d.ts +8 -8
  110. package/dist/storage.js +8 -8
  111. package/dist/tokens.d.ts +3 -1
  112. package/dist/tokens.js +3 -1
  113. package/dist/transfer/types.d.ts +66 -66
  114. package/dist/verified-recall.js +8 -8
  115. package/package.json +2 -2
  116. package/src/access-boundary.ts +2 -0
  117. package/src/access-errors.ts +2 -0
  118. package/src/access-http.test.ts +350 -0
  119. package/src/access-http.ts +192 -7
  120. package/src/access-mcp.ts +120 -3
  121. package/src/access-operations-batch.ts +19 -0
  122. package/src/access-surface-catalog.ts +1 -0
  123. package/src/index.ts +3 -0
  124. package/src/orchestration/recall-internal.ts +21 -3
  125. package/src/recall-timings-access.test.ts +301 -0
  126. package/src/recall-timings.ts +141 -0
  127. package/src/tokens.test.ts +58 -0
  128. package/src/tokens.ts +19 -8
  129. package/dist/chunk-GLERZDM7.js.map +0 -1
  130. package/dist/chunk-J3UJJZKI.js.map +0 -1
  131. package/dist/chunk-PA5ALD3S.js.map +0 -1
  132. package/dist/chunk-TVLN5EZZ.js.map +0 -1
  133. package/dist/chunk-UG274TNV.js.map +0 -1
  134. /package/dist/{chunk-R3K4CXPR.js.map → chunk-3JOFWTQD.js.map} +0 -0
  135. /package/dist/{chunk-QOGXUNWL.js.map → chunk-3O3BZNSH.js.map} +0 -0
  136. /package/dist/{chunk-SFNQDXXL.js.map → chunk-3YYWUHA7.js.map} +0 -0
  137. /package/dist/{chunk-N4ZA2I7M.js.map → chunk-5ACKQLGA.js.map} +0 -0
  138. /package/dist/{chunk-YDYC6PIS.js.map → chunk-5QJZLJ7Q.js.map} +0 -0
  139. /package/dist/{chunk-ORAK3LNZ.js.map → chunk-6SHAUM5S.js.map} +0 -0
  140. /package/dist/{chunk-CKNAVW34.js.map → chunk-7OSULYN2.js.map} +0 -0
  141. /package/dist/{chunk-6T4LTI2F.js.map → chunk-7XH7VJN4.js.map} +0 -0
  142. /package/dist/{chunk-OX272PBW.js.map → chunk-CSHUSYUM.js.map} +0 -0
  143. /package/dist/{chunk-HBOPSFQQ.js.map → chunk-E6LXM2GE.js.map} +0 -0
  144. /package/dist/{chunk-OAREUC7N.js.map → chunk-EWGMA5W5.js.map} +0 -0
  145. /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
  146. /package/dist/{chunk-2RLECZ6J.js.map → chunk-IOENSSZC.js.map} +0 -0
  147. /package/dist/{chunk-VKHZFF5P.js.map → chunk-KFIAFHOC.js.map} +0 -0
  148. /package/dist/{chunk-NDXJJKT2.js.map → chunk-KMKBD66B.js.map} +0 -0
  149. /package/dist/{chunk-SQNED75R.js.map → chunk-KYC3RLYJ.js.map} +0 -0
  150. /package/dist/{chunk-V5RVMULT.js.map → chunk-MLDAD27I.js.map} +0 -0
  151. /package/dist/{chunk-IGVKWKR2.js.map → chunk-O5EKTMOJ.js.map} +0 -0
  152. /package/dist/{chunk-4HYFKIGH.js.map → chunk-PSNMICC2.js.map} +0 -0
  153. /package/dist/{chunk-IVFIYAIP.js.map → chunk-QKUWPSU3.js.map} +0 -0
  154. /package/dist/{chunk-4DLFJJOQ.js.map → chunk-QMN3CIFS.js.map} +0 -0
  155. /package/dist/{chunk-AS2NRB45.js.map → chunk-RZ3KJZYE.js.map} +0 -0
  156. /package/dist/{chunk-4JG55YV3.js.map → chunk-SRSPVFSJ.js.map} +0 -0
  157. /package/dist/{chunk-DTTVX6EO.js.map → chunk-STN5KMSS.js.map} +0 -0
  158. /package/dist/{chunk-QPTP6SKC.js.map → chunk-U72WWTLR.js.map} +0 -0
  159. /package/dist/{chunk-U5GFYMV7.js.map → chunk-W7VA5667.js.map} +0 -0
  160. /package/dist/{chunk-X7HTU4HO.js.map → chunk-WB4CFKZ4.js.map} +0 -0
  161. /package/dist/{chunk-NFWTMM2M.js.map → chunk-XDRJQMYL.js.map} +0 -0
  162. /package/dist/{chunk-CVINPTKG.js.map → chunk-ZG4FT6IT.js.map} +0 -0
@@ -1454,3 +1454,353 @@ test("HTTP offline apply requires a changeset", async () => {
1454
1454
  await server.stop();
1455
1455
  }
1456
1456
  });
1457
+
1458
+ test("HTTP server rejects invalid resourceMetadataUrl at construction", () => {
1459
+ const service = {} as EngramAccessService;
1460
+ for (const bad of ["not a url", "ftp://example.com/oauth", "//relative/path", ""]) {
1461
+ assert.throws(
1462
+ () =>
1463
+ new EngramAccessHttpServer({
1464
+ service,
1465
+ port: 0,
1466
+ authToken: "test-token",
1467
+ adminConsoleEnabled: false,
1468
+ resourceMetadataUrl: bad,
1469
+ }),
1470
+ /access HTTP resourceMetadataUrl/,
1471
+ `resourceMetadataUrl=${JSON.stringify(bad)} must be rejected`,
1472
+ );
1473
+ }
1474
+ // http and https are accepted.
1475
+ for (const ok of [
1476
+ "https://example.com/.well-known/oauth-protected-resource",
1477
+ "http://127.0.0.1:8787/.well-known/oauth-protected-resource",
1478
+ ]) {
1479
+ const server = new EngramAccessHttpServer({
1480
+ service,
1481
+ port: 0,
1482
+ authToken: "test-token",
1483
+ adminConsoleEnabled: false,
1484
+ resourceMetadataUrl: ok,
1485
+ });
1486
+ assert.ok(server, `resourceMetadataUrl=${ok} should be accepted`);
1487
+ }
1488
+ });
1489
+
1490
+ test("HTTP 401 www-authenticate carries resource_metadata exactly when configured", async () => {
1491
+ const service = {} as EngramAccessService;
1492
+ const metadataUrl = "https://example.test/.well-known/oauth-protected-resource";
1493
+ const server = new EngramAccessHttpServer({
1494
+ service,
1495
+ port: 0,
1496
+ authToken: "test-token",
1497
+ adminConsoleEnabled: false,
1498
+ resourceMetadataUrl: metadataUrl,
1499
+ });
1500
+ const status = await server.start();
1501
+ try {
1502
+ const response = await fetch(`http://127.0.0.1:${status.port}/engram/v1/health`);
1503
+ assert.equal(response.status, 401);
1504
+ assert.equal(
1505
+ response.headers.get("www-authenticate"),
1506
+ `Bearer resource_metadata="${metadataUrl}"`,
1507
+ );
1508
+ } finally {
1509
+ await server.stop();
1510
+ }
1511
+ });
1512
+
1513
+ test("HTTP 401 www-authenticate is the bare Bearer challenge when resourceMetadataUrl is unset", async () => {
1514
+ const service = {} as EngramAccessService;
1515
+ const server = new EngramAccessHttpServer({
1516
+ service,
1517
+ port: 0,
1518
+ authToken: "test-token",
1519
+ adminConsoleEnabled: false,
1520
+ });
1521
+ const status = await server.start();
1522
+ try {
1523
+ const response = await fetch(`http://127.0.0.1:${status.port}/engram/v1/health`);
1524
+ assert.equal(response.status, 401);
1525
+ assert.equal(response.headers.get("www-authenticate"), "Bearer");
1526
+ } finally {
1527
+ await server.stop();
1528
+ }
1529
+ });
1530
+
1531
+ test("HTTP /mcp returns 405 with Allow: POST for GET and DELETE (authorized requests)", async () => {
1532
+ const service = {} as EngramAccessService;
1533
+ const server = new EngramAccessHttpServer({
1534
+ service,
1535
+ port: 0,
1536
+ authToken: "test-token",
1537
+ adminConsoleEnabled: false,
1538
+ });
1539
+ const status = await server.start();
1540
+ try {
1541
+ for (const method of ["GET", "DELETE"]) {
1542
+ const response = await fetch(`http://127.0.0.1:${status.port}/mcp`, {
1543
+ method,
1544
+ headers: { authorization: "Bearer test-token" },
1545
+ });
1546
+ assert.equal(response.status, 405, `${method} /mcp must be 405`);
1547
+ assert.equal(response.headers.get("allow"), "POST", `${method} /mcp must advertise Allow: POST`);
1548
+ const body = await response.json() as { code?: string };
1549
+ assert.equal(body.code, "method_not_allowed");
1550
+ }
1551
+ // Unauthenticated GET /mcp still gets 401 first (auth gate beats method-conformance).
1552
+ const unauth = await fetch(`http://127.0.0.1:${status.port}/mcp`, { method: "GET" });
1553
+ assert.equal(unauth.status, 401);
1554
+ } finally {
1555
+ await server.stop();
1556
+ }
1557
+ });
1558
+
1559
+ test("HTTP /mcp rejects unknown MCP-Protocol-Version header with 400 JSON-RPC error", async () => {
1560
+ const service = {} as EngramAccessService;
1561
+ const server = new EngramAccessHttpServer({
1562
+ service,
1563
+ port: 0,
1564
+ authToken: "test-token",
1565
+ adminConsoleEnabled: false,
1566
+ });
1567
+ const status = await server.start();
1568
+ try {
1569
+ const response = await fetch(`http://127.0.0.1:${status.port}/mcp`, {
1570
+ method: "POST",
1571
+ headers: {
1572
+ authorization: "Bearer test-token",
1573
+ "content-type": "application/json",
1574
+ "mcp-protocol-version": "1999-01-01",
1575
+ },
1576
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
1577
+ });
1578
+ assert.equal(response.status, 400);
1579
+ const body = await response.json() as { jsonrpc?: string; error?: { message?: string } };
1580
+ assert.equal(body.jsonrpc, "2.0");
1581
+ assert.match(body.error?.message ?? "", /unsupported MCP-Protocol-Version/);
1582
+
1583
+ // A supported header is accepted (and a valid request proceeds normally).
1584
+ for (const v of ["2025-06-18", "2025-03-26", "2024-11-05"]) {
1585
+ const ok = await fetch(`http://127.0.0.1:${status.port}/mcp`, {
1586
+ method: "POST",
1587
+ headers: {
1588
+ authorization: "Bearer test-token",
1589
+ "content-type": "application/json",
1590
+ "mcp-protocol-version": v,
1591
+ },
1592
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
1593
+ });
1594
+ assert.equal(ok.status, 200, `version ${v} should be accepted`);
1595
+ }
1596
+
1597
+ // Absent header is also fine.
1598
+ const absent = await fetch(`http://127.0.0.1:${status.port}/mcp`, {
1599
+ method: "POST",
1600
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
1601
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
1602
+ });
1603
+ assert.equal(absent.status, 200);
1604
+ } finally {
1605
+ await server.stop();
1606
+ }
1607
+ });
1608
+
1609
+ test("HTTP externalRequestHandler runs pre-auth, can end responses, and falls through on false", async () => {
1610
+ // Minimal service stub: the fall-through leg hits /engram/v1/health, which
1611
+ // calls service.health(). The stub is signature-faithful (full
1612
+ // EngramAccessHealthResponse via `satisfies`) so interface drift fails here
1613
+ // instead of passing vacuously; everything else in this test bypasses the
1614
+ // service.
1615
+ const healthStub = {
1616
+ health: async () => ({
1617
+ ok: true as const,
1618
+ memoryDir: "/tmp/remnic-test",
1619
+ namespacesEnabled: false,
1620
+ defaultNamespace: "default",
1621
+ searchBackend: "recent",
1622
+ qmdEnabled: false,
1623
+ qmd: {
1624
+ enabled: false,
1625
+ active: false,
1626
+ degraded: false,
1627
+ mode: "disabled" as const,
1628
+ collection: "",
1629
+ collectionState: "skipped" as const,
1630
+ installedVersion: null,
1631
+ supportedVersion: null,
1632
+ supported: null,
1633
+ upgradeAvailable: null,
1634
+ doctorAvailable: null,
1635
+ debugStatus: "disabled",
1636
+ },
1637
+ nativeKnowledgeEnabled: false,
1638
+ projectionAvailable: false,
1639
+ }),
1640
+ } satisfies Pick<EngramAccessService, "health">;
1641
+ const service = healthStub as EngramAccessService;
1642
+ const seen: Array<{ path: string; method: string; authorized: boolean }> = [];
1643
+ const server = new EngramAccessHttpServer({
1644
+ service,
1645
+ port: 0,
1646
+ authToken: "test-token",
1647
+ adminConsoleEnabled: false,
1648
+ externalRequestHandler: async (_req, res, ctx) => {
1649
+ seen.push({
1650
+ path: new URL(_req.url ?? "/", "http://placeholder").pathname,
1651
+ method: _req.method ?? "",
1652
+ authorized: ctx.authorized,
1653
+ });
1654
+ if (new URL(_req.url ?? "/", "http://placeholder").pathname === "/probe/handled") {
1655
+ res.writeHead(200, { "content-type": "application/json" });
1656
+ res.end(JSON.stringify({ handled: true, authorized: ctx.authorized }));
1657
+ return true;
1658
+ }
1659
+ return false; // fall through to the normal pipeline
1660
+ },
1661
+ });
1662
+ const status = await server.start();
1663
+ try {
1664
+ // Pre-auth, the handler sees authorized=false (no token sent).
1665
+ const handled = await fetch(`http://127.0.0.1:${status.port}/probe/handled`);
1666
+ assert.equal(handled.status, 200);
1667
+ const handledBody = await handled.json() as { handled?: boolean; authorized?: boolean };
1668
+ assert.equal(handledBody.handled, true);
1669
+ assert.equal(handledBody.authorized, false, "handler must observe authorized=false pre-token");
1670
+
1671
+ // Fall-through path: same handler returns false, request continues to normal
1672
+ // routing. Hit a real endpoint so we know the request reached it.
1673
+ const passthrough = await fetch(
1674
+ `http://127.0.0.1:${status.port}/engram/v1/health`,
1675
+ { headers: { authorization: "Bearer test-token" } },
1676
+ );
1677
+ assert.equal(passthrough.status, 200, "fall-through should reach the normal health route");
1678
+ const passthroughBody = await passthrough.json() as { ok?: boolean; memoryDir?: string };
1679
+ assert.equal(passthroughBody.ok, true, "fall-through must return the stubbed health payload");
1680
+ assert.equal(passthroughBody.memoryDir, "/tmp/remnic-test");
1681
+
1682
+ // Authorized request: handler sees ctx.authorized=true.
1683
+ const authed = await fetch(
1684
+ `http://127.0.0.1:${status.port}/probe/handled`,
1685
+ { headers: { authorization: "Bearer test-token" } },
1686
+ );
1687
+ assert.equal(authed.status, 200);
1688
+ const authedBody = await authed.json() as { authorized?: boolean };
1689
+ assert.equal(authedBody.authorized, true, "handler must observe authorized=true with valid token");
1690
+
1691
+ assert.deepEqual(seen, [
1692
+ { path: "/probe/handled", method: "GET", authorized: false },
1693
+ { path: "/engram/v1/health", method: "GET", authorized: true },
1694
+ { path: "/probe/handled", method: "GET", authorized: true },
1695
+ ]);
1696
+ } finally {
1697
+ await server.stop();
1698
+ }
1699
+ });
1700
+
1701
+ test("HTTP externalRequestHandler errors flow through the existing error handler", async () => {
1702
+ const service = {} as EngramAccessService;
1703
+ const server = new EngramAccessHttpServer({
1704
+ service,
1705
+ port: 0,
1706
+ authToken: "test-token",
1707
+ adminConsoleEnabled: false,
1708
+ externalRequestHandler: async () => {
1709
+ throw new Error("external-handler-explosion");
1710
+ },
1711
+ });
1712
+ const status = await server.start();
1713
+ try {
1714
+ const response = await fetch(`http://127.0.0.1:${status.port}/engram/v1/health`);
1715
+ assert.equal(response.status, 500, "thrown errors must produce a 500 via the existing error handler");
1716
+ const body = await response.json() as { code?: string };
1717
+ assert.equal(body.code, "internal_error");
1718
+ } finally {
1719
+ await server.stop();
1720
+ }
1721
+ });
1722
+
1723
+ test("HTTP authTokenEntriesGetter is authoritative: scope policy binds connectors and never falls through", async () => {
1724
+ // Signature-faithful health stub so non-MCP authorization outcomes are
1725
+ // observable as 200-with-body (a bare `{}` service would 500 and mask
1726
+ // accidental policy application).
1727
+ const healthStub = {
1728
+ health: async () => ({
1729
+ ok: true as const,
1730
+ memoryDir: "/tmp/remnic-scope-test",
1731
+ namespacesEnabled: false,
1732
+ defaultNamespace: "default",
1733
+ searchBackend: "recent",
1734
+ qmdEnabled: false,
1735
+ qmd: {
1736
+ enabled: false,
1737
+ active: false,
1738
+ degraded: false,
1739
+ mode: "disabled" as const,
1740
+ collection: "",
1741
+ collectionState: "skipped" as const,
1742
+ installedVersion: null,
1743
+ supportedVersion: null,
1744
+ supported: null,
1745
+ upgradeAvailable: null,
1746
+ doctorAvailable: null,
1747
+ debugStatus: "disabled",
1748
+ },
1749
+ nativeKnowledgeEnabled: false,
1750
+ projectionAvailable: false,
1751
+ }),
1752
+ } satisfies Pick<EngramAccessService, "health">;
1753
+ const entries = [
1754
+ { token: "remnic_cg_scoped", connector: "chatgpt" },
1755
+ { token: "remnic_cx_free", connector: "codex" },
1756
+ { token: "remnic_xx_anon" }, // no connector — must fail closed under a policy
1757
+ ];
1758
+ const server = new EngramAccessHttpServer({
1759
+ service: healthStub as EngramAccessService,
1760
+ port: 0,
1761
+ authToken: "operator-token",
1762
+ // Dangerous shape on purpose: BOTH getters configured, and the string
1763
+ // getter is a superset (extra "string_only_token"). The entries getter
1764
+ // must decide alone; nothing may leak into the string getter.
1765
+ authTokensGetter: () => [...entries.map((entry) => entry.token), "string_only_token"],
1766
+ authTokenEntriesGetter: () => entries,
1767
+ tokenPathPolicy: (connector, pathname) => connector !== "chatgpt" || pathname === "/mcp",
1768
+ adminConsoleEnabled: false,
1769
+ });
1770
+ const status = await server.start();
1771
+ const request = (token: string, path: string, method = "GET") =>
1772
+ fetch(`http://127.0.0.1:${status.port}${path}`, {
1773
+ method,
1774
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1775
+ ...(method === "POST" ? { body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }) } : {}),
1776
+ });
1777
+ try {
1778
+ // Scoped chatgpt token: /mcp only; health denied.
1779
+ assert.equal((await request("remnic_cg_scoped", "/mcp", "POST")).status, 200);
1780
+ assert.equal(
1781
+ (await request("remnic_cg_scoped", "/engram/v1/health")).status,
1782
+ 401,
1783
+ "chatgpt token must be denied off /mcp even with a permissive string getter present",
1784
+ );
1785
+ // Other connector tokens are unrestricted by this policy: /mcp AND health.
1786
+ assert.equal((await request("remnic_cx_free", "/mcp", "POST")).status, 200);
1787
+ const codexHealth = await request("remnic_cx_free", "/engram/v1/health");
1788
+ assert.equal(codexHealth.status, 200);
1789
+ assert.equal(((await codexHealth.json()) as { ok?: boolean }).ok, true);
1790
+ // Entry without connector fails closed when a policy is configured.
1791
+ assert.equal((await request("remnic_xx_anon", "/mcp", "POST")).status, 401);
1792
+ // A token present ONLY in the string getter is NOT honored: the entries
1793
+ // getter is authoritative and there is no fall-through.
1794
+ assert.equal((await request("string_only_token", "/mcp", "POST")).status, 401);
1795
+ assert.equal((await request("string_only_token", "/engram/v1/health")).status, 401);
1796
+ // Unknown tokens are rejected everywhere.
1797
+ assert.equal((await request("remnic_zz_unknown", "/mcp", "POST")).status, 401);
1798
+ // Static operator token bypasses the policy entirely: /mcp AND health.
1799
+ assert.equal((await request("operator-token", "/mcp", "POST")).status, 200);
1800
+ const operatorHealth = await request("operator-token", "/engram/v1/health");
1801
+ assert.equal(operatorHealth.status, 200);
1802
+ assert.equal(((await operatorHealth.json()) as { ok?: boolean }).ok, true);
1803
+ } finally {
1804
+ await server.stop();
1805
+ }
1806
+ });
@@ -8,10 +8,16 @@ import { fileURLToPath, URL } from "node:url";
8
8
  import { gunzipSync } from "node:zlib";
9
9
  import { log } from "./logger.js";
10
10
  import { abortError, isAbortError } from "./abort-error.js";
11
- import { EngramAccessInputError, type EngramAccessService, type EngramAccessMemoryResponse, type EngramAccessWriteResponse } from "./access-service.js";
11
+ import { EngramAccessForbiddenError } from "./access-errors.js";
12
+ import {
13
+ EngramAccessInputError,
14
+ type EngramAccessService,
15
+ type EngramAccessMemoryResponse,
16
+ type EngramAccessWriteResponse,
17
+ } from "./access-service.js";
12
18
  import { CorrectionContractError } from "./correction/correction-contract.js";
13
19
  import { WearablesInputError } from "./wearables/errors.js";
14
- import { EngramMcpServer } from "./access-mcp.js";
20
+ import { EngramMcpServer, MCP_SUPPORTED_PROTOCOL_VERSIONS } from "./access-mcp.js";
15
21
  import { validateRequest, type SchemaName, type SchemaTypeFor } from "./access-schema.js";
16
22
  import {
17
23
  OFFLINE_SYNC_APPLY_MAX_BODY_BYTES,
@@ -54,6 +60,21 @@ export interface EngramAccessHttpServerOptions {
54
60
  authTokens?: string[];
55
61
  /** Dynamic token loader — called on each auth check so new/revoked tokens take effect without restart. */
56
62
  authTokensGetter?: () => string[];
63
+ /**
64
+ * Dynamic token-ENTRY loader ({token, connector} pairs from one coherent
65
+ * snapshot). Preferred over `authTokensGetter` when a `tokenPathPolicy`
66
+ * is set: the connector used for the policy decision comes from the SAME
67
+ * entry that validated, so identity can never lag validation.
68
+ */
69
+ authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
70
+ /**
71
+ * Optional per-request scope policy for tokens sourced from
72
+ * `authTokenEntriesGetter`. Return false to deny the (validated) token
73
+ * for this pathname. Static `authToken`/`authTokens` (operator-supplied)
74
+ * bypass the policy. Entries whose connector is missing FAIL CLOSED when
75
+ * a policy is configured.
76
+ */
77
+ tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
57
78
  principal?: string;
58
79
  maxBodyBytes?: number;
59
80
  adminConsoleEnabled?: boolean;
@@ -78,6 +99,28 @@ export interface EngramAccessHttpServerOptions {
78
99
  * existing health behavior.
79
100
  */
80
101
  readiness?: () => AccessHttpReadinessState;
102
+ /**
103
+ * When set, every 401 response includes
104
+ * `WWW-Authenticate: Bearer resource_metadata="<value>"` so MCP clients
105
+ * can discover the OAuth 2.0 protected-resource metadata document
106
+ * (RFC 9728). Must be an absolute http(s) URL; constructor throws on
107
+ * anything else. Unset → bare `Bearer`.
108
+ */
109
+ resourceMetadataUrl?: string;
110
+ /**
111
+ * Optional pre-auth request handler (e.g. OAuth facade mounted by
112
+ * `@remnic/server`). Runs after the admin-console handler and BEFORE
113
+ * bearer authorization. Return true if the request was fully handled
114
+ * (response ended). `ctx.authorized` reports whether the request
115
+ * carries a valid operator bearer token, so the handler can gate
116
+ * operator-only endpoints without owning token validation.
117
+ * Errors thrown by the handler flow into the existing error handling.
118
+ */
119
+ externalRequestHandler?: (
120
+ req: IncomingMessage,
121
+ res: ServerResponse,
122
+ ctx: { authorized: boolean },
123
+ ) => Promise<boolean>;
81
124
  }
82
125
 
83
126
  export interface EngramAccessHttpServerStatus {
@@ -183,6 +226,23 @@ function parseHttpServerPort(port: number | undefined): number {
183
226
  }
184
227
  return port;
185
228
  }
229
+ function assertResourceMetadataUrl(value: string | undefined): string | undefined {
230
+ if (value === undefined) return undefined;
231
+ let parsed: URL;
232
+ try {
233
+ parsed = new URL(value);
234
+ } catch {
235
+ throw new Error(
236
+ `access HTTP resourceMetadataUrl must be an absolute http(s) URL, got: ${value}`,
237
+ );
238
+ }
239
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
240
+ throw new Error(
241
+ `access HTTP resourceMetadataUrl must use http or https, got: ${parsed.protocol}`,
242
+ );
243
+ }
244
+ return value;
245
+ }
186
246
 
187
247
  function parseTrustZoneKindFilter(raw: string | null): TrustZoneRecordKind | undefined {
188
248
  if (raw === null) return undefined;
@@ -283,6 +343,8 @@ export class EngramAccessHttpServer {
283
343
  private readonly authToken?: string;
284
344
  private readonly authTokens: string[];
285
345
  private readonly authTokensGetter?: () => string[];
346
+ private readonly authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
347
+ private readonly tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
286
348
  private readonly authenticatedPrincipal?: string;
287
349
  private readonly maxBodyBytes: number;
288
350
  private readonly adminConsoleEnabled: boolean;
@@ -292,6 +354,12 @@ export class EngramAccessHttpServer {
292
354
  private readonly trustPrincipalHeader: boolean;
293
355
  private readonly adapterRegistry: AdapterRegistry | null;
294
356
  private readonly readiness: () => AccessHttpReadinessState;
357
+ private readonly resourceMetadataUrl?: string;
358
+ private readonly externalRequestHandler?: (
359
+ req: IncomingMessage,
360
+ res: ServerResponse,
361
+ ctx: { authorized: boolean },
362
+ ) => Promise<boolean>;
295
363
  private readonly writeRequestTimestamps: number[] = [];
296
364
  private readonly mcpServer: EngramMcpServer;
297
365
  private server: Server | null = null;
@@ -323,6 +391,8 @@ export class EngramAccessHttpServer {
323
391
  this.authToken = options.authToken?.trim() || undefined;
324
392
  this.authTokens = (options.authTokens ?? []).map((t) => t.trim()).filter(Boolean);
325
393
  this.authTokensGetter = options.authTokensGetter;
394
+ this.authTokenEntriesGetter = options.authTokenEntriesGetter;
395
+ this.tokenPathPolicy = options.tokenPathPolicy;
326
396
  this.authenticatedPrincipal = options.principal?.trim() || undefined;
327
397
  this.maxBodyBytes = Number.isFinite(options.maxBodyBytes)
328
398
  ? Math.max(1, Math.floor(options.maxBodyBytes ?? 131072))
@@ -333,6 +403,8 @@ export class EngramAccessHttpServer {
333
403
  this.adminControls = options.adminControls;
334
404
  this.trustPrincipalHeader = options.trustPrincipalHeader === true;
335
405
  this.readiness = options.readiness ?? (() => ({ ready: true, warmupAttempts: 0 }));
406
+ this.resourceMetadataUrl = assertResourceMetadataUrl(options.resourceMetadataUrl);
407
+ this.externalRequestHandler = options.externalRequestHandler;
336
408
  this.adapterRegistry = options.enableAdapters !== false
337
409
  ? (options.adapterRegistry ?? new AdapterRegistry())
338
410
  : null;
@@ -351,7 +423,7 @@ export class EngramAccessHttpServer {
351
423
  }
352
424
 
353
425
  async start(): Promise<EngramAccessHttpServerStatus> {
354
- if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter) {
426
+ if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter && !this.authTokenEntriesGetter) {
355
427
  throw new Error("engram access HTTP requires authToken or authTokens");
356
428
  }
357
429
  if (this.server) return this.status();
@@ -385,6 +457,10 @@ export class EngramAccessHttpServer {
385
457
  this.respondJson(res, 400, { error: err.message, code: "input_error" });
386
458
  return;
387
459
  }
460
+ if (err instanceof EngramAccessForbiddenError) {
461
+ this.respondJson(res, 403, { error: err.message, code: "forbidden" });
462
+ return;
463
+ }
388
464
  if (err instanceof CorrectionContractError) {
389
465
  this.respondJson(res, 400, { error: err.message, code: "correction_contract_error" });
390
466
  return;
@@ -642,11 +718,40 @@ export class EngramAccessHttpServer {
642
718
 
643
719
  }
644
720
 
721
+ // Run any host-supplied pre-auth request handler. It runs AFTER the
722
+ // admin-console branch (admin assets are public) and BEFORE the
723
+ // operator bearer gate. The handler decides whether it has fully
724
+ // owned the response (return true) or wants the request to fall
725
+ // through to the normal pipeline. `ctx.authorized` is computed
726
+ // here so the handler can implement operator-only endpoints
727
+ // (e.g. /oauth/pending) without owning token validation.
728
+ if (this.externalRequestHandler) {
729
+ const authorized = this.isAuthorized(req, pathname);
730
+ if (await this.externalRequestHandler(req, res, { authorized })) {
731
+ return;
732
+ }
733
+ }
734
+
645
735
  if (!this.isAuthorized(req, pathname)) {
646
736
  const body = JSON.stringify({ error: "unauthorized", code: "unauthorized" });
647
737
  res.writeHead(401, {
648
738
  "content-type": "application/json; charset=utf-8",
649
- "www-authenticate": "Bearer",
739
+ "www-authenticate": this.bearerChallenge(),
740
+ "x-request-id": correlationId,
741
+ });
742
+ res.end(body);
743
+ return;
744
+ }
745
+
746
+ // Method-conformance for the streamable-HTTP MCP endpoint:
747
+ // GET/DELETE on /mcp must return 405 + Allow: POST instead of
748
+ // silently falling through to the generic 404. POST continues
749
+ // to the normal handler below.
750
+ if (pathname === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
751
+ const body = JSON.stringify({ error: "method_not_allowed", code: "method_not_allowed" });
752
+ res.writeHead(405, {
753
+ "content-type": "application/json; charset=utf-8",
754
+ allow: "POST",
650
755
  "x-request-id": correlationId,
651
756
  });
652
757
  res.end(body);
@@ -1674,6 +1779,27 @@ export class EngramAccessHttpServer {
1674
1779
  return;
1675
1780
  }
1676
1781
 
1782
+ if (req.method === "GET" && pathname === "/engram/v1/recall/timings") {
1783
+ const op = getOperation("recall_timings"); // boundary dispatch (issue #1830)
1784
+ if (!op) {
1785
+ throw new Error("access-boundary: operation not registered: recall_timings");
1786
+ }
1787
+ // Live diagnostic route; no response (success or denial) is cacheable.
1788
+ // Set before dispatch so the 403 path carries the header too.
1789
+ res.setHeader("cache-control", "no-store");
1790
+ // Dispatch through the registered operation so the HTTP route and the
1791
+ // boundary registration share one gate. The server's own principal is
1792
+ // the transport-level operator fallback; a configured
1793
+ // agentAccessHttp.principal outranks it.
1794
+ const output = (await op.run({}, {
1795
+ service: this.service,
1796
+ authenticatedPrincipal: this.resolveRequestPrincipal(req),
1797
+ operatorPrincipal: this.authenticatedPrincipal,
1798
+ })) as { result: unknown };
1799
+ this.respondJson(res, 200, output.result);
1800
+ return;
1801
+ }
1802
+
1677
1803
  const memoryMatch = pathname.match(/^\/engram\/v1\/memories\/([^/]+)$/);
1678
1804
  if (req.method === "GET" && memoryMatch) {
1679
1805
  const memoryId = decodeURIComponent(memoryMatch[1] ?? "");
@@ -2682,7 +2808,28 @@ export class EngramAccessHttpServer {
2682
2808
  }
2683
2809
 
2684
2810
  private async handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
2811
+ // Reject requests that advertise an unknown MCP protocol version in
2812
+ // the streamable-HTTP `MCP-Protocol-Version` header. Absent or
2813
+ // valid → proceed. Unknown → 400 with a JSON-RPC-shaped error so
2814
+ // the client surfaces a clear message. The supported set is
2815
+ // exported by @remnic/core's access-mcp module to keep the
2816
+ // version policy in a single place.
2817
+ const headerVersion = req.headers["mcp-protocol-version"];
2818
+ if (typeof headerVersion === "string" && headerVersion.length > 0) {
2819
+ if (!(MCP_SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(headerVersion)) {
2820
+ this.respondJson(res, 400, {
2821
+ jsonrpc: "2.0",
2822
+ id: null,
2823
+ error: {
2824
+ code: -32000,
2825
+ message: `unsupported MCP-Protocol-Version: ${headerVersion}; supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}`,
2826
+ },
2827
+ });
2828
+ return;
2829
+ }
2830
+ }
2685
2831
  const body = await this.readJsonBody(req);
2832
+
2686
2833
  const request = body as {
2687
2834
  jsonrpc?: string;
2688
2835
  id?: string | number | null;
@@ -3118,8 +3265,29 @@ export class EngramAccessHttpServer {
3118
3265
  return result.data as SchemaTypeFor<S>;
3119
3266
  }
3120
3267
 
3121
- private isAuthorized(req: IncomingMessage, pathname?: string): boolean {
3122
- if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter) return false;
3268
+ /**
3269
+ * Build the WWW-Authenticate challenge string for 401 responses.
3270
+ * When `resourceMetadataUrl` is configured, includes the RFC 9728
3271
+ * `resource_metadata` parameter so MCP clients (e.g. ChatGPT) can
3272
+ * discover the OAuth 2.0 protected-resource metadata document.
3273
+ * Otherwise the bare `Bearer` challenge is returned (unchanged).
3274
+ */
3275
+ private bearerChallenge(): string {
3276
+ if (this.resourceMetadataUrl) {
3277
+ return `Bearer resource_metadata="${this.resourceMetadataUrl}"`;
3278
+ }
3279
+ return "Bearer";
3280
+ }
3281
+
3282
+ private isAuthorized(req: IncomingMessage, pathname?: string): boolean {
3283
+ if (
3284
+ !this.authToken &&
3285
+ this.authTokens.length === 0 &&
3286
+ !this.authTokensGetter &&
3287
+ !this.authTokenEntriesGetter
3288
+ ) {
3289
+ return false;
3290
+ }
3123
3291
  // Primary path: Authorization: Bearer <token> header.
3124
3292
  const raw = req.headers.authorization;
3125
3293
  let candidate: string | null = null;
@@ -3159,7 +3327,24 @@ export class EngramAccessHttpServer {
3159
3327
  for (const valid of this.authTokens) {
3160
3328
  if (this.timingSafeStringEqual(token, valid)) return true;
3161
3329
  }
3162
- // Check dynamic tokens (reloaded per request for generate/revoke without restart)
3330
+ // Entry-based dynamic tokens are AUTHORITATIVE when configured: the
3331
+ // dynamic-token decision ends here (no fall-through to the string
3332
+ // getter, which carries no identity and would bypass the policy).
3333
+ // Validation and connector identity come from the same snapshot entry,
3334
+ // so a scope policy can never observe a token fresher than the
3335
+ // identity it scopes (mint/revoke coherence).
3336
+ if (this.authTokenEntriesGetter) {
3337
+ for (const entry of this.authTokenEntriesGetter()) {
3338
+ if (!this.timingSafeStringEqual(token, entry.token)) continue;
3339
+ if (!this.tokenPathPolicy) return true;
3340
+ // Fail closed: a policy without a connector identity denies.
3341
+ if (typeof entry.connector !== "string" || entry.connector.length === 0) return false;
3342
+ return this.tokenPathPolicy(entry.connector, pathname);
3343
+ }
3344
+ return false;
3345
+ }
3346
+ // String-token getter (no identity, no policy) — only consulted when
3347
+ // no entry getter is configured.
3163
3348
  if (this.authTokensGetter) {
3164
3349
  for (const valid of this.authTokensGetter()) {
3165
3350
  if (this.timingSafeStringEqual(token, valid)) return true;