@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.
- package/dist/access-admin-ops-surface.js +29 -29
- package/dist/access-boundary.d.ts +2 -1
- package/dist/access-boundary.js +30 -30
- package/dist/access-cli.js +85 -83
- package/dist/access-cli.js.map +1 -1
- package/dist/access-errors.d.ts +5 -0
- package/dist/access-errors.js +8 -0
- package/dist/access-errors.js.map +1 -0
- package/dist/access-http.d.ts +51 -1
- package/dist/access-http.js +38 -36
- package/dist/access-identity-continuity-surface.js +29 -29
- package/dist/access-lcm-surface.js +29 -29
- package/dist/access-mcp.d.ts +7 -1
- package/dist/access-mcp.js +41 -37
- package/dist/access-observe-write-surface.js +29 -29
- package/dist/access-operations-batch.js +34 -32
- package/dist/access-operations.d.ts +4 -4
- package/dist/access-operations.js +36 -34
- package/dist/access-recall-surface.js +29 -29
- package/dist/access-schema.d.ts +68 -68
- package/dist/access-service.js +29 -29
- package/dist/access-surface-catalog.js +1 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/active-recall.js +5 -5
- package/dist/auto-sync-2SOUW4JW.js +1 -1
- package/dist/briefing.js +7 -7
- package/dist/causal-behavior.js +5 -5
- package/dist/causal-chain.js +3 -3
- package/dist/causal-consolidation.js +11 -11
- package/dist/causal-retrieval.js +3 -3
- package/dist/causal-trajectory.js +1 -1
- package/dist/{chunk-R3K4CXPR.js → chunk-3JOFWTQD.js} +2 -2
- package/dist/{chunk-QOGXUNWL.js → chunk-3O3BZNSH.js} +4 -4
- package/dist/{chunk-SFNQDXXL.js → chunk-3YYWUHA7.js} +3 -3
- package/dist/{chunk-GLERZDM7.js → chunk-52TEPNL7.js} +29 -7
- package/dist/chunk-52TEPNL7.js.map +1 -0
- package/dist/{chunk-N4ZA2I7M.js → chunk-5ACKQLGA.js} +2 -2
- package/dist/{chunk-YDYC6PIS.js → chunk-5QJZLJ7Q.js} +2 -2
- package/dist/{chunk-ORAK3LNZ.js → chunk-6SHAUM5S.js} +5 -5
- package/dist/{chunk-CKNAVW34.js → chunk-7OSULYN2.js} +2 -2
- package/dist/{chunk-PA5ALD3S.js → chunk-7XGF5HCC.js} +106 -86
- package/dist/chunk-7XGF5HCC.js.map +1 -0
- package/dist/{chunk-6T4LTI2F.js → chunk-7XH7VJN4.js} +4 -4
- package/dist/{chunk-OX272PBW.js → chunk-CSHUSYUM.js} +2 -2
- package/dist/{chunk-UG274TNV.js → chunk-E2SPGGUI.js} +13 -8
- package/dist/chunk-E2SPGGUI.js.map +1 -0
- package/dist/{chunk-HBOPSFQQ.js → chunk-E6LXM2GE.js} +41 -41
- package/dist/{chunk-OAREUC7N.js → chunk-EWGMA5W5.js} +28 -28
- package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
- package/dist/chunk-FTYDWKO6.js +101 -0
- package/dist/chunk-FTYDWKO6.js.map +1 -0
- package/dist/{chunk-2RLECZ6J.js → chunk-IOENSSZC.js} +2 -2
- package/dist/{chunk-VKHZFF5P.js → chunk-KFIAFHOC.js} +2 -2
- package/dist/{chunk-NDXJJKT2.js → chunk-KMKBD66B.js} +2 -2
- package/dist/{chunk-SQNED75R.js → chunk-KYC3RLYJ.js} +1 -1
- package/dist/{chunk-V5RVMULT.js → chunk-MLDAD27I.js} +7 -7
- package/dist/{chunk-IGVKWKR2.js → chunk-O5EKTMOJ.js} +6 -6
- package/dist/{chunk-TVLN5EZZ.js → chunk-OP43P53G.js} +83 -8
- package/dist/chunk-OP43P53G.js.map +1 -0
- package/dist/{chunk-4HYFKIGH.js → chunk-PSNMICC2.js} +4 -4
- package/dist/{chunk-IVFIYAIP.js → chunk-QKUWPSU3.js} +5 -5
- package/dist/{chunk-4DLFJJOQ.js → chunk-QMN3CIFS.js} +2 -2
- package/dist/chunk-RANHFI3P.js +8 -0
- package/dist/chunk-RANHFI3P.js.map +1 -0
- package/dist/{chunk-AS2NRB45.js → chunk-RZ3KJZYE.js} +2 -2
- package/dist/{chunk-4JG55YV3.js → chunk-SRSPVFSJ.js} +3 -3
- package/dist/{chunk-DTTVX6EO.js → chunk-STN5KMSS.js} +12 -12
- package/dist/{chunk-J3UJJZKI.js → chunk-TGROC2UP.js} +108 -7
- package/dist/chunk-TGROC2UP.js.map +1 -0
- package/dist/{chunk-QPTP6SKC.js → chunk-U72WWTLR.js} +2 -2
- package/dist/{chunk-U5GFYMV7.js → chunk-W7VA5667.js} +3 -3
- package/dist/{chunk-X7HTU4HO.js → chunk-WB4CFKZ4.js} +2 -2
- package/dist/{chunk-NFWTMM2M.js → chunk-XDRJQMYL.js} +2 -2
- package/dist/{chunk-CVINPTKG.js → chunk-ZG4FT6IT.js} +5 -5
- package/dist/{chunk-MSGPLITO.js → chunk-ZT34X56N.js} +2 -2
- package/dist/{chunk-MSGPLITO.js.map → chunk-ZT34X56N.js.map} +1 -1
- package/dist/cli.js +58 -56
- package/dist/compounding/engine.js +7 -7
- package/dist/config.js +2 -2
- package/dist/connectors/codex-materialize-runner.js +7 -7
- package/dist/connectors/index.js +9 -9
- package/dist/consolidation-provenance-check.js +2 -2
- package/dist/dashboard-runtime.js +2 -2
- package/dist/entity-retrieval.js +7 -7
- package/dist/extraction.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +141 -133
- package/dist/lcm/engine.js +3 -3
- package/dist/lcm/index.js +11 -11
- package/dist/maintenance/memory-governance.js +8 -8
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
- package/dist/maintenance/rebuild-memory-projection.js +9 -9
- package/dist/namespaces/migrate.js +12 -12
- package/dist/namespaces/search.js +4 -4
- package/dist/namespaces/storage.js +7 -7
- package/dist/operator-toolkit.js +17 -17
- package/dist/orchestration/maintenance.js +9 -9
- package/dist/orchestrator.js +85 -83
- package/dist/recall-timings.d.ts +26 -0
- package/dist/recall-timings.js +16 -0
- package/dist/recall-timings.js.map +1 -0
- package/dist/resume-bundles.js +3 -3
- package/dist/schemas.d.ts +84 -84
- package/dist/search/factory.js +3 -3
- package/dist/search/index.js +7 -7
- package/dist/semantic-consolidation.js +10 -10
- package/dist/semantic-rule-promotion.js +7 -7
- package/dist/semantic-rule-verifier.js +8 -8
- package/dist/shared-context/manager.d.ts +8 -8
- package/dist/storage.js +8 -8
- package/dist/tokens.d.ts +3 -1
- package/dist/tokens.js +3 -1
- package/dist/transfer/types.d.ts +66 -66
- package/dist/verified-recall.js +8 -8
- package/package.json +2 -2
- package/src/access-boundary.ts +2 -0
- package/src/access-errors.ts +2 -0
- package/src/access-http.test.ts +350 -0
- package/src/access-http.ts +192 -7
- package/src/access-mcp.ts +120 -3
- package/src/access-operations-batch.ts +19 -0
- package/src/access-surface-catalog.ts +1 -0
- package/src/index.ts +3 -0
- package/src/orchestration/recall-internal.ts +21 -3
- package/src/recall-timings-access.test.ts +301 -0
- package/src/recall-timings.ts +141 -0
- package/src/tokens.test.ts +58 -0
- package/src/tokens.ts +19 -8
- package/dist/chunk-GLERZDM7.js.map +0 -1
- package/dist/chunk-J3UJJZKI.js.map +0 -1
- package/dist/chunk-PA5ALD3S.js.map +0 -1
- package/dist/chunk-TVLN5EZZ.js.map +0 -1
- package/dist/chunk-UG274TNV.js.map +0 -1
- /package/dist/{chunk-R3K4CXPR.js.map → chunk-3JOFWTQD.js.map} +0 -0
- /package/dist/{chunk-QOGXUNWL.js.map → chunk-3O3BZNSH.js.map} +0 -0
- /package/dist/{chunk-SFNQDXXL.js.map → chunk-3YYWUHA7.js.map} +0 -0
- /package/dist/{chunk-N4ZA2I7M.js.map → chunk-5ACKQLGA.js.map} +0 -0
- /package/dist/{chunk-YDYC6PIS.js.map → chunk-5QJZLJ7Q.js.map} +0 -0
- /package/dist/{chunk-ORAK3LNZ.js.map → chunk-6SHAUM5S.js.map} +0 -0
- /package/dist/{chunk-CKNAVW34.js.map → chunk-7OSULYN2.js.map} +0 -0
- /package/dist/{chunk-6T4LTI2F.js.map → chunk-7XH7VJN4.js.map} +0 -0
- /package/dist/{chunk-OX272PBW.js.map → chunk-CSHUSYUM.js.map} +0 -0
- /package/dist/{chunk-HBOPSFQQ.js.map → chunk-E6LXM2GE.js.map} +0 -0
- /package/dist/{chunk-OAREUC7N.js.map → chunk-EWGMA5W5.js.map} +0 -0
- /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
- /package/dist/{chunk-2RLECZ6J.js.map → chunk-IOENSSZC.js.map} +0 -0
- /package/dist/{chunk-VKHZFF5P.js.map → chunk-KFIAFHOC.js.map} +0 -0
- /package/dist/{chunk-NDXJJKT2.js.map → chunk-KMKBD66B.js.map} +0 -0
- /package/dist/{chunk-SQNED75R.js.map → chunk-KYC3RLYJ.js.map} +0 -0
- /package/dist/{chunk-V5RVMULT.js.map → chunk-MLDAD27I.js.map} +0 -0
- /package/dist/{chunk-IGVKWKR2.js.map → chunk-O5EKTMOJ.js.map} +0 -0
- /package/dist/{chunk-4HYFKIGH.js.map → chunk-PSNMICC2.js.map} +0 -0
- /package/dist/{chunk-IVFIYAIP.js.map → chunk-QKUWPSU3.js.map} +0 -0
- /package/dist/{chunk-4DLFJJOQ.js.map → chunk-QMN3CIFS.js.map} +0 -0
- /package/dist/{chunk-AS2NRB45.js.map → chunk-RZ3KJZYE.js.map} +0 -0
- /package/dist/{chunk-4JG55YV3.js.map → chunk-SRSPVFSJ.js.map} +0 -0
- /package/dist/{chunk-DTTVX6EO.js.map → chunk-STN5KMSS.js.map} +0 -0
- /package/dist/{chunk-QPTP6SKC.js.map → chunk-U72WWTLR.js.map} +0 -0
- /package/dist/{chunk-U5GFYMV7.js.map → chunk-W7VA5667.js.map} +0 -0
- /package/dist/{chunk-X7HTU4HO.js.map → chunk-WB4CFKZ4.js.map} +0 -0
- /package/dist/{chunk-NFWTMM2M.js.map → chunk-XDRJQMYL.js.map} +0 -0
- /package/dist/{chunk-CVINPTKG.js.map → chunk-ZG4FT6IT.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
|
@@ -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 {
|
|
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":
|
|
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
|
-
|
|
3122
|
-
|
|
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
|
-
//
|
|
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;
|