@remnic/core 9.3.768 → 9.3.769
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.
- package/dist/access-cli.js +5 -5
- package/dist/access-http.d.ts +51 -1
- package/dist/access-http.js +2 -2
- package/dist/access-mcp.d.ts +7 -1
- package/dist/access-mcp.js +5 -3
- package/dist/access-operations.d.ts +4 -4
- package/dist/access-schema.d.ts +68 -68
- package/dist/{chunk-TVLN5EZZ.js → chunk-CNXMWYLA.js} +78 -3
- package/dist/chunk-CNXMWYLA.js.map +1 -0
- package/dist/{chunk-UG274TNV.js → chunk-E2SPGGUI.js} +13 -8
- package/dist/chunk-E2SPGGUI.js.map +1 -0
- package/dist/{chunk-HBOPSFQQ.js → chunk-EVXI2I6G.js} +3 -3
- package/dist/{chunk-4DLFJJOQ.js → chunk-QMN3CIFS.js} +2 -2
- package/dist/{chunk-J3UJJZKI.js → chunk-SIPZ5UMK.js} +85 -5
- package/dist/chunk-SIPZ5UMK.js.map +1 -0
- package/dist/cli.js +3 -3
- package/dist/connectors/index.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +11 -5
- package/dist/orchestrator.js +5 -5
- package/dist/schemas.d.ts +84 -84
- package/dist/shared-context/manager.d.ts +8 -8
- package/dist/tokens.d.ts +3 -1
- package/dist/tokens.js +3 -1
- package/dist/transfer/types.d.ts +66 -66
- package/package.json +2 -2
- package/src/access-http.test.ts +350 -0
- package/src/access-http.ts +160 -6
- package/src/access-mcp.ts +120 -3
- package/src/index.ts +3 -0
- package/src/tokens.test.ts +58 -0
- package/src/tokens.ts +19 -8
- package/dist/chunk-J3UJJZKI.js.map +0 -1
- package/dist/chunk-TVLN5EZZ.js.map +0 -1
- package/dist/chunk-UG274TNV.js.map +0 -1
- /package/dist/{chunk-HBOPSFQQ.js.map → chunk-EVXI2I6G.js.map} +0 -0
- /package/dist/{chunk-4DLFJJOQ.js.map → chunk-QMN3CIFS.js.map} +0 -0
package/src/access-http.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/access-http.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { abortError, isAbortError } from "./abort-error.js";
|
|
|
11
11
|
import { EngramAccessInputError, type EngramAccessService, type EngramAccessMemoryResponse, type EngramAccessWriteResponse } from "./access-service.js";
|
|
12
12
|
import { CorrectionContractError } from "./correction/correction-contract.js";
|
|
13
13
|
import { WearablesInputError } from "./wearables/errors.js";
|
|
14
|
-
import { EngramMcpServer } from "./access-mcp.js";
|
|
14
|
+
import { EngramMcpServer, MCP_SUPPORTED_PROTOCOL_VERSIONS } from "./access-mcp.js";
|
|
15
15
|
import { validateRequest, type SchemaName, type SchemaTypeFor } from "./access-schema.js";
|
|
16
16
|
import {
|
|
17
17
|
OFFLINE_SYNC_APPLY_MAX_BODY_BYTES,
|
|
@@ -54,6 +54,21 @@ export interface EngramAccessHttpServerOptions {
|
|
|
54
54
|
authTokens?: string[];
|
|
55
55
|
/** Dynamic token loader — called on each auth check so new/revoked tokens take effect without restart. */
|
|
56
56
|
authTokensGetter?: () => string[];
|
|
57
|
+
/**
|
|
58
|
+
* Dynamic token-ENTRY loader ({token, connector} pairs from one coherent
|
|
59
|
+
* snapshot). Preferred over `authTokensGetter` when a `tokenPathPolicy`
|
|
60
|
+
* is set: the connector used for the policy decision comes from the SAME
|
|
61
|
+
* entry that validated, so identity can never lag validation.
|
|
62
|
+
*/
|
|
63
|
+
authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
|
|
64
|
+
/**
|
|
65
|
+
* Optional per-request scope policy for tokens sourced from
|
|
66
|
+
* `authTokenEntriesGetter`. Return false to deny the (validated) token
|
|
67
|
+
* for this pathname. Static `authToken`/`authTokens` (operator-supplied)
|
|
68
|
+
* bypass the policy. Entries whose connector is missing FAIL CLOSED when
|
|
69
|
+
* a policy is configured.
|
|
70
|
+
*/
|
|
71
|
+
tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
|
|
57
72
|
principal?: string;
|
|
58
73
|
maxBodyBytes?: number;
|
|
59
74
|
adminConsoleEnabled?: boolean;
|
|
@@ -78,6 +93,28 @@ export interface EngramAccessHttpServerOptions {
|
|
|
78
93
|
* existing health behavior.
|
|
79
94
|
*/
|
|
80
95
|
readiness?: () => AccessHttpReadinessState;
|
|
96
|
+
/**
|
|
97
|
+
* When set, every 401 response includes
|
|
98
|
+
* `WWW-Authenticate: Bearer resource_metadata="<value>"` so MCP clients
|
|
99
|
+
* can discover the OAuth 2.0 protected-resource metadata document
|
|
100
|
+
* (RFC 9728). Must be an absolute http(s) URL; constructor throws on
|
|
101
|
+
* anything else. Unset → bare `Bearer`.
|
|
102
|
+
*/
|
|
103
|
+
resourceMetadataUrl?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Optional pre-auth request handler (e.g. OAuth facade mounted by
|
|
106
|
+
* `@remnic/server`). Runs after the admin-console handler and BEFORE
|
|
107
|
+
* bearer authorization. Return true if the request was fully handled
|
|
108
|
+
* (response ended). `ctx.authorized` reports whether the request
|
|
109
|
+
* carries a valid operator bearer token, so the handler can gate
|
|
110
|
+
* operator-only endpoints without owning token validation.
|
|
111
|
+
* Errors thrown by the handler flow into the existing error handling.
|
|
112
|
+
*/
|
|
113
|
+
externalRequestHandler?: (
|
|
114
|
+
req: IncomingMessage,
|
|
115
|
+
res: ServerResponse,
|
|
116
|
+
ctx: { authorized: boolean },
|
|
117
|
+
) => Promise<boolean>;
|
|
81
118
|
}
|
|
82
119
|
|
|
83
120
|
export interface EngramAccessHttpServerStatus {
|
|
@@ -183,6 +220,23 @@ function parseHttpServerPort(port: number | undefined): number {
|
|
|
183
220
|
}
|
|
184
221
|
return port;
|
|
185
222
|
}
|
|
223
|
+
function assertResourceMetadataUrl(value: string | undefined): string | undefined {
|
|
224
|
+
if (value === undefined) return undefined;
|
|
225
|
+
let parsed: URL;
|
|
226
|
+
try {
|
|
227
|
+
parsed = new URL(value);
|
|
228
|
+
} catch {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`access HTTP resourceMetadataUrl must be an absolute http(s) URL, got: ${value}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`access HTTP resourceMetadataUrl must use http or https, got: ${parsed.protocol}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
186
240
|
|
|
187
241
|
function parseTrustZoneKindFilter(raw: string | null): TrustZoneRecordKind | undefined {
|
|
188
242
|
if (raw === null) return undefined;
|
|
@@ -283,6 +337,8 @@ export class EngramAccessHttpServer {
|
|
|
283
337
|
private readonly authToken?: string;
|
|
284
338
|
private readonly authTokens: string[];
|
|
285
339
|
private readonly authTokensGetter?: () => string[];
|
|
340
|
+
private readonly authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
|
|
341
|
+
private readonly tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
|
|
286
342
|
private readonly authenticatedPrincipal?: string;
|
|
287
343
|
private readonly maxBodyBytes: number;
|
|
288
344
|
private readonly adminConsoleEnabled: boolean;
|
|
@@ -292,6 +348,12 @@ export class EngramAccessHttpServer {
|
|
|
292
348
|
private readonly trustPrincipalHeader: boolean;
|
|
293
349
|
private readonly adapterRegistry: AdapterRegistry | null;
|
|
294
350
|
private readonly readiness: () => AccessHttpReadinessState;
|
|
351
|
+
private readonly resourceMetadataUrl?: string;
|
|
352
|
+
private readonly externalRequestHandler?: (
|
|
353
|
+
req: IncomingMessage,
|
|
354
|
+
res: ServerResponse,
|
|
355
|
+
ctx: { authorized: boolean },
|
|
356
|
+
) => Promise<boolean>;
|
|
295
357
|
private readonly writeRequestTimestamps: number[] = [];
|
|
296
358
|
private readonly mcpServer: EngramMcpServer;
|
|
297
359
|
private server: Server | null = null;
|
|
@@ -323,6 +385,8 @@ export class EngramAccessHttpServer {
|
|
|
323
385
|
this.authToken = options.authToken?.trim() || undefined;
|
|
324
386
|
this.authTokens = (options.authTokens ?? []).map((t) => t.trim()).filter(Boolean);
|
|
325
387
|
this.authTokensGetter = options.authTokensGetter;
|
|
388
|
+
this.authTokenEntriesGetter = options.authTokenEntriesGetter;
|
|
389
|
+
this.tokenPathPolicy = options.tokenPathPolicy;
|
|
326
390
|
this.authenticatedPrincipal = options.principal?.trim() || undefined;
|
|
327
391
|
this.maxBodyBytes = Number.isFinite(options.maxBodyBytes)
|
|
328
392
|
? Math.max(1, Math.floor(options.maxBodyBytes ?? 131072))
|
|
@@ -333,6 +397,8 @@ export class EngramAccessHttpServer {
|
|
|
333
397
|
this.adminControls = options.adminControls;
|
|
334
398
|
this.trustPrincipalHeader = options.trustPrincipalHeader === true;
|
|
335
399
|
this.readiness = options.readiness ?? (() => ({ ready: true, warmupAttempts: 0 }));
|
|
400
|
+
this.resourceMetadataUrl = assertResourceMetadataUrl(options.resourceMetadataUrl);
|
|
401
|
+
this.externalRequestHandler = options.externalRequestHandler;
|
|
336
402
|
this.adapterRegistry = options.enableAdapters !== false
|
|
337
403
|
? (options.adapterRegistry ?? new AdapterRegistry())
|
|
338
404
|
: null;
|
|
@@ -351,7 +417,7 @@ export class EngramAccessHttpServer {
|
|
|
351
417
|
}
|
|
352
418
|
|
|
353
419
|
async start(): Promise<EngramAccessHttpServerStatus> {
|
|
354
|
-
if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter) {
|
|
420
|
+
if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter && !this.authTokenEntriesGetter) {
|
|
355
421
|
throw new Error("engram access HTTP requires authToken or authTokens");
|
|
356
422
|
}
|
|
357
423
|
if (this.server) return this.status();
|
|
@@ -642,11 +708,40 @@ export class EngramAccessHttpServer {
|
|
|
642
708
|
|
|
643
709
|
}
|
|
644
710
|
|
|
711
|
+
// Run any host-supplied pre-auth request handler. It runs AFTER the
|
|
712
|
+
// admin-console branch (admin assets are public) and BEFORE the
|
|
713
|
+
// operator bearer gate. The handler decides whether it has fully
|
|
714
|
+
// owned the response (return true) or wants the request to fall
|
|
715
|
+
// through to the normal pipeline. `ctx.authorized` is computed
|
|
716
|
+
// here so the handler can implement operator-only endpoints
|
|
717
|
+
// (e.g. /oauth/pending) without owning token validation.
|
|
718
|
+
if (this.externalRequestHandler) {
|
|
719
|
+
const authorized = this.isAuthorized(req, pathname);
|
|
720
|
+
if (await this.externalRequestHandler(req, res, { authorized })) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
645
725
|
if (!this.isAuthorized(req, pathname)) {
|
|
646
726
|
const body = JSON.stringify({ error: "unauthorized", code: "unauthorized" });
|
|
647
727
|
res.writeHead(401, {
|
|
648
728
|
"content-type": "application/json; charset=utf-8",
|
|
649
|
-
"www-authenticate":
|
|
729
|
+
"www-authenticate": this.bearerChallenge(),
|
|
730
|
+
"x-request-id": correlationId,
|
|
731
|
+
});
|
|
732
|
+
res.end(body);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Method-conformance for the streamable-HTTP MCP endpoint:
|
|
737
|
+
// GET/DELETE on /mcp must return 405 + Allow: POST instead of
|
|
738
|
+
// silently falling through to the generic 404. POST continues
|
|
739
|
+
// to the normal handler below.
|
|
740
|
+
if (pathname === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
|
|
741
|
+
const body = JSON.stringify({ error: "method_not_allowed", code: "method_not_allowed" });
|
|
742
|
+
res.writeHead(405, {
|
|
743
|
+
"content-type": "application/json; charset=utf-8",
|
|
744
|
+
allow: "POST",
|
|
650
745
|
"x-request-id": correlationId,
|
|
651
746
|
});
|
|
652
747
|
res.end(body);
|
|
@@ -2682,7 +2777,28 @@ export class EngramAccessHttpServer {
|
|
|
2682
2777
|
}
|
|
2683
2778
|
|
|
2684
2779
|
private async handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
2780
|
+
// Reject requests that advertise an unknown MCP protocol version in
|
|
2781
|
+
// the streamable-HTTP `MCP-Protocol-Version` header. Absent or
|
|
2782
|
+
// valid → proceed. Unknown → 400 with a JSON-RPC-shaped error so
|
|
2783
|
+
// the client surfaces a clear message. The supported set is
|
|
2784
|
+
// exported by @remnic/core's access-mcp module to keep the
|
|
2785
|
+
// version policy in a single place.
|
|
2786
|
+
const headerVersion = req.headers["mcp-protocol-version"];
|
|
2787
|
+
if (typeof headerVersion === "string" && headerVersion.length > 0) {
|
|
2788
|
+
if (!(MCP_SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(headerVersion)) {
|
|
2789
|
+
this.respondJson(res, 400, {
|
|
2790
|
+
jsonrpc: "2.0",
|
|
2791
|
+
id: null,
|
|
2792
|
+
error: {
|
|
2793
|
+
code: -32000,
|
|
2794
|
+
message: `unsupported MCP-Protocol-Version: ${headerVersion}; supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}`,
|
|
2795
|
+
},
|
|
2796
|
+
});
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2685
2800
|
const body = await this.readJsonBody(req);
|
|
2801
|
+
|
|
2686
2802
|
const request = body as {
|
|
2687
2803
|
jsonrpc?: string;
|
|
2688
2804
|
id?: string | number | null;
|
|
@@ -3118,8 +3234,29 @@ export class EngramAccessHttpServer {
|
|
|
3118
3234
|
return result.data as SchemaTypeFor<S>;
|
|
3119
3235
|
}
|
|
3120
3236
|
|
|
3121
|
-
|
|
3122
|
-
|
|
3237
|
+
/**
|
|
3238
|
+
* Build the WWW-Authenticate challenge string for 401 responses.
|
|
3239
|
+
* When `resourceMetadataUrl` is configured, includes the RFC 9728
|
|
3240
|
+
* `resource_metadata` parameter so MCP clients (e.g. ChatGPT) can
|
|
3241
|
+
* discover the OAuth 2.0 protected-resource metadata document.
|
|
3242
|
+
* Otherwise the bare `Bearer` challenge is returned (unchanged).
|
|
3243
|
+
*/
|
|
3244
|
+
private bearerChallenge(): string {
|
|
3245
|
+
if (this.resourceMetadataUrl) {
|
|
3246
|
+
return `Bearer resource_metadata="${this.resourceMetadataUrl}"`;
|
|
3247
|
+
}
|
|
3248
|
+
return "Bearer";
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
private isAuthorized(req: IncomingMessage, pathname?: string): boolean {
|
|
3252
|
+
if (
|
|
3253
|
+
!this.authToken &&
|
|
3254
|
+
this.authTokens.length === 0 &&
|
|
3255
|
+
!this.authTokensGetter &&
|
|
3256
|
+
!this.authTokenEntriesGetter
|
|
3257
|
+
) {
|
|
3258
|
+
return false;
|
|
3259
|
+
}
|
|
3123
3260
|
// Primary path: Authorization: Bearer <token> header.
|
|
3124
3261
|
const raw = req.headers.authorization;
|
|
3125
3262
|
let candidate: string | null = null;
|
|
@@ -3159,7 +3296,24 @@ export class EngramAccessHttpServer {
|
|
|
3159
3296
|
for (const valid of this.authTokens) {
|
|
3160
3297
|
if (this.timingSafeStringEqual(token, valid)) return true;
|
|
3161
3298
|
}
|
|
3162
|
-
//
|
|
3299
|
+
// Entry-based dynamic tokens are AUTHORITATIVE when configured: the
|
|
3300
|
+
// dynamic-token decision ends here (no fall-through to the string
|
|
3301
|
+
// getter, which carries no identity and would bypass the policy).
|
|
3302
|
+
// Validation and connector identity come from the same snapshot entry,
|
|
3303
|
+
// so a scope policy can never observe a token fresher than the
|
|
3304
|
+
// identity it scopes (mint/revoke coherence).
|
|
3305
|
+
if (this.authTokenEntriesGetter) {
|
|
3306
|
+
for (const entry of this.authTokenEntriesGetter()) {
|
|
3307
|
+
if (!this.timingSafeStringEqual(token, entry.token)) continue;
|
|
3308
|
+
if (!this.tokenPathPolicy) return true;
|
|
3309
|
+
// Fail closed: a policy without a connector identity denies.
|
|
3310
|
+
if (typeof entry.connector !== "string" || entry.connector.length === 0) return false;
|
|
3311
|
+
return this.tokenPathPolicy(entry.connector, pathname);
|
|
3312
|
+
}
|
|
3313
|
+
return false;
|
|
3314
|
+
}
|
|
3315
|
+
// String-token getter (no identity, no policy) — only consulted when
|
|
3316
|
+
// no entry getter is configured.
|
|
3163
3317
|
if (this.authTokensGetter) {
|
|
3164
3318
|
for (const valid of this.authTokensGetter()) {
|
|
3165
3319
|
if (this.timingSafeStringEqual(token, valid)) return true;
|