@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.
Files changed (37) hide show
  1. package/dist/access-cli.js +5 -5
  2. package/dist/access-http.d.ts +51 -1
  3. package/dist/access-http.js +2 -2
  4. package/dist/access-mcp.d.ts +7 -1
  5. package/dist/access-mcp.js +5 -3
  6. package/dist/access-operations.d.ts +4 -4
  7. package/dist/access-schema.d.ts +68 -68
  8. package/dist/{chunk-TVLN5EZZ.js → chunk-CNXMWYLA.js} +78 -3
  9. package/dist/chunk-CNXMWYLA.js.map +1 -0
  10. package/dist/{chunk-UG274TNV.js → chunk-E2SPGGUI.js} +13 -8
  11. package/dist/chunk-E2SPGGUI.js.map +1 -0
  12. package/dist/{chunk-HBOPSFQQ.js → chunk-EVXI2I6G.js} +3 -3
  13. package/dist/{chunk-4DLFJJOQ.js → chunk-QMN3CIFS.js} +2 -2
  14. package/dist/{chunk-J3UJJZKI.js → chunk-SIPZ5UMK.js} +85 -5
  15. package/dist/chunk-SIPZ5UMK.js.map +1 -0
  16. package/dist/cli.js +3 -3
  17. package/dist/connectors/index.js +2 -2
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +11 -5
  20. package/dist/orchestrator.js +5 -5
  21. package/dist/schemas.d.ts +84 -84
  22. package/dist/shared-context/manager.d.ts +8 -8
  23. package/dist/tokens.d.ts +3 -1
  24. package/dist/tokens.js +3 -1
  25. package/dist/transfer/types.d.ts +66 -66
  26. package/package.json +2 -2
  27. package/src/access-http.test.ts +350 -0
  28. package/src/access-http.ts +160 -6
  29. package/src/access-mcp.ts +120 -3
  30. package/src/index.ts +3 -0
  31. package/src/tokens.test.ts +58 -0
  32. package/src/tokens.ts +19 -8
  33. package/dist/chunk-J3UJJZKI.js.map +0 -1
  34. package/dist/chunk-TVLN5EZZ.js.map +0 -1
  35. package/dist/chunk-UG274TNV.js.map +0 -1
  36. /package/dist/{chunk-HBOPSFQQ.js.map → chunk-EVXI2I6G.js.map} +0 -0
  37. /package/dist/{chunk-4DLFJJOQ.js.map → chunk-QMN3CIFS.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
+ });
@@ -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": "Bearer",
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
- private isAuthorized(req: IncomingMessage, pathname?: string): boolean {
3122
- if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter) return false;
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
- // Check dynamic tokens (reloaded per request for generate/revoke without restart)
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;