neotoma 0.22.1 → 0.22.2
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/actions.d.ts +13 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +236 -93
- package/dist/actions.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +11 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +90 -0
- package/dist/config.js.map +1 -1
- package/dist/docs/api/rest_api.md +81 -0
- package/dist/docs/developer/getting_started.md +45 -0
- package/dist/docs/developer/mcp/proxy.md +48 -28
- package/dist/docs/developer/npm_scripts.md +10 -4
- package/dist/docs/infrastructure/client_instance_deployment.md +50 -2
- package/dist/docs/operations/configuration.md +44 -1
- package/dist/docs/subsystems/agent_session_architecture.md +69 -0
- package/dist/docs/subsystems/usage_digest.md +127 -0
- package/dist/docs/testing/automated_test_catalog.md +49 -26
- package/dist/docs/testing/testing_standard.md +1 -1
- package/dist/middleware/aauth_admission.js +1 -1
- package/dist/middleware/aauth_admission.js.map +1 -1
- package/dist/middleware/db_abort_context.d.ts +24 -0
- package/dist/middleware/db_abort_context.d.ts.map +1 -0
- package/dist/middleware/db_abort_context.js +41 -0
- package/dist/middleware/db_abort_context.js.map +1 -0
- package/dist/proxy/mcp_stdio_proxy.d.ts +134 -6
- package/dist/proxy/mcp_stdio_proxy.d.ts.map +1 -1
- package/dist/proxy/mcp_stdio_proxy.js +373 -17
- package/dist/proxy/mcp_stdio_proxy.js.map +1 -1
- package/dist/repositories/sqlite/local_db_adapter.d.ts.map +1 -1
- package/dist/repositories/sqlite/local_db_adapter.js +20 -0
- package/dist/repositories/sqlite/local_db_adapter.js.map +1 -1
- package/dist/repositories/sqlite/sqlite_client.d.ts.map +1 -1
- package/dist/repositories/sqlite/sqlite_client.js +10 -0
- package/dist/repositories/sqlite/sqlite_client.js.map +1 -1
- package/dist/repositories/worker/worker_file_database.d.ts +135 -1
- package/dist/repositories/worker/worker_file_database.d.ts.map +1 -1
- package/dist/repositories/worker/worker_file_database.js +726 -27
- package/dist/repositories/worker/worker_file_database.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +182 -77
- package/dist/server.js.map +1 -1
- package/dist/services/attachment_resolution.d.ts +149 -0
- package/dist/services/attachment_resolution.d.ts.map +1 -0
- package/dist/services/attachment_resolution.js +206 -0
- package/dist/services/attachment_resolution.js.map +1 -0
- package/dist/services/attachment_resolution_sqlite.d.ts +57 -0
- package/dist/services/attachment_resolution_sqlite.d.ts.map +1 -0
- package/dist/services/attachment_resolution_sqlite.js +75 -0
- package/dist/services/attachment_resolution_sqlite.js.map +1 -0
- package/dist/services/entity_queries.d.ts.map +1 -1
- package/dist/services/entity_queries.js +85 -23
- package/dist/services/entity_queries.js.map +1 -1
- package/dist/services/entity_snapshot_at_time.d.ts.map +1 -1
- package/dist/services/entity_snapshot_at_time.js +23 -3
- package/dist/services/entity_snapshot_at_time.js.map +1 -1
- package/dist/services/file_input_diagnostics.d.ts +89 -0
- package/dist/services/file_input_diagnostics.d.ts.map +1 -0
- package/dist/services/file_input_diagnostics.js +174 -0
- package/dist/services/file_input_diagnostics.js.map +1 -0
- package/dist/services/interpretation.d.ts.map +1 -1
- package/dist/services/interpretation.js +22 -8
- package/dist/services/interpretation.js.map +1 -1
- package/dist/services/mcp_oauth.d.ts +14 -1
- package/dist/services/mcp_oauth.d.ts.map +1 -1
- package/dist/services/mcp_oauth.js +86 -1
- package/dist/services/mcp_oauth.js.map +1 -1
- package/dist/services/raw_storage.d.ts.map +1 -1
- package/dist/services/raw_storage.js +14 -2
- package/dist/services/raw_storage.js.map +1 -1
- package/dist/services/readiness.d.ts +85 -0
- package/dist/services/readiness.d.ts.map +1 -0
- package/dist/services/readiness.js +93 -0
- package/dist/services/readiness.js.map +1 -0
- package/dist/services/route_shadowing.d.ts +67 -0
- package/dist/services/route_shadowing.d.ts.map +1 -0
- package/dist/services/route_shadowing.js +126 -0
- package/dist/services/route_shadowing.js.map +1 -0
- package/dist/services/schema_definitions.d.ts.map +1 -1
- package/dist/services/schema_definitions.js +171 -0
- package/dist/services/schema_definitions.js.map +1 -1
- package/dist/services/schema_lag_repair.d.ts.map +1 -1
- package/dist/services/schema_lag_repair.js +32 -12
- package/dist/services/schema_lag_repair.js.map +1 -1
- package/dist/services/schema_registry.d.ts.map +1 -1
- package/dist/services/schema_registry.js +12 -5
- package/dist/services/schema_registry.js.map +1 -1
- package/dist/services/snapshot_computation.d.ts.map +1 -1
- package/dist/services/snapshot_computation.js +12 -8
- package/dist/services/snapshot_computation.js.map +1 -1
- package/dist/services/subscriptions/sse_hub.d.ts.map +1 -1
- package/dist/services/subscriptions/sse_hub.js +19 -1
- package/dist/services/subscriptions/sse_hub.js.map +1 -1
- package/dist/shared/action_handlers/entity_handlers.d.ts.map +1 -1
- package/dist/shared/action_handlers/entity_handlers.js +110 -26
- package/dist/shared/action_handlers/entity_handlers.js.map +1 -1
- package/dist/shared/action_schemas.d.ts +6 -6
- package/dist/shared/contract_mappings.d.ts.map +1 -1
- package/dist/shared/contract_mappings.js +8 -1
- package/dist/shared/contract_mappings.js.map +1 -1
- package/dist/shared/openapi_types.d.ts +108 -3
- package/dist/shared/openapi_types.d.ts.map +1 -1
- package/dist/tool_definitions.d.ts.map +1 -1
- package/dist/tool_definitions.js +18 -13
- package/dist/tool_definitions.js.map +1 -1
- package/openapi.yaml +83 -3
- package/package.json +2 -1
- package/skills/status/SKILL.md +0 -1
- package/skills/where/SKILL.md +315 -0
package/dist/actions.d.ts
CHANGED
|
@@ -141,6 +141,19 @@ type GuestPrincipal = {
|
|
|
141
141
|
accessToken?: string;
|
|
142
142
|
};
|
|
143
143
|
type RoutePrincipal = UserPrincipal | GuestPrincipal;
|
|
144
|
+
/**
|
|
145
|
+
* Static path segments under `/entities/` that are routes in their own right,
|
|
146
|
+
* not entity ids. The `/entities/:id` family of guest-eligible reads must never
|
|
147
|
+
* treat these as an id (issue #2208): `/entities/duplicates` matched the
|
|
148
|
+
* `:id`-shaped guest regex below, so a guest principal could be stamped onto a
|
|
149
|
+
* route whose handler calls `getAuthenticatedUserId()` and 500s.
|
|
150
|
+
*
|
|
151
|
+
* Keep in sync with the `app.get("/entities/<segment>")` registrations. The
|
|
152
|
+
* boot-time assertion in `assertNoShadowedRoutes()` fails the process if a new
|
|
153
|
+
* static `/entities/*` route is added without being registered ahead of
|
|
154
|
+
* `/entities/:id`, which is the other half of this invariant.
|
|
155
|
+
*/
|
|
156
|
+
export declare const RESERVED_ENTITY_PATH_SEGMENTS: Set<string>;
|
|
144
157
|
/** Exported for unit tests: routes where guest (AAuth / guest token) may be stamped before handlers run. */
|
|
145
158
|
export declare function routeAcceptsGuestPrincipal(req: Pick<express.Request, "method" | "path">): boolean;
|
|
146
159
|
/**
|
package/dist/actions.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"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"}
|
package/dist/actions.js
CHANGED
|
@@ -18,11 +18,14 @@ import { encryptResponseMiddleware } from "./middleware/encrypt_response.js";
|
|
|
18
18
|
import { unknownFieldsGuard } from "./middleware/unknown_fields_guard.js";
|
|
19
19
|
import { aauthVerify, getAAuthContextFromRequest, getAttributionDecisionFromRequest, } from "./middleware/aauth_verify.js";
|
|
20
20
|
import { attributionContext } from "./middleware/attribution_context.js";
|
|
21
|
+
import { dbAbortContext } from "./middleware/db_abort_context.js";
|
|
21
22
|
import { aauthAdmission, getAAuthAdmissionFromRequest } from "./middleware/aauth_admission.js";
|
|
22
23
|
import { buildSessionInfo, normalizeSessionOrigin } from "./services/session_info.js";
|
|
24
|
+
import { probeReadiness } from "./services/readiness.js";
|
|
23
25
|
import { AttributionPolicyError, enforceAttributionPolicy } from "./services/attribution_policy.js";
|
|
24
26
|
import { OverridePolicyViolationError } from "./services/override_validation.js";
|
|
25
27
|
import { CursorError } from "./services/entity_cursor.js";
|
|
28
|
+
import { assertNoShadowedRoutes } from "./services/route_shadowing.js";
|
|
26
29
|
import { StorePolicyUnavailableError } from "./services/instance_policy.js";
|
|
27
30
|
import { AgentCapabilityError, contextFromAgentIdentity, enforceAgentCapability, } from "./services/agent_capabilities.js";
|
|
28
31
|
import { assertGuestWriteAllowed, AccessPolicyError, } from "./services/access_policy.js";
|
|
@@ -34,6 +37,7 @@ import { createAgentIdentity as buildAgentIdentity, getAgentIdentityFromRequest,
|
|
|
34
37
|
import { initServerKeys } from "./services/encryption_service.js";
|
|
35
38
|
import { storeRawContent, downloadRawContent, resolveLocalSourceFilePath, SourceFileNotFoundError, } from "./services/raw_storage.js";
|
|
36
39
|
import { attachSourceLabelsToObservations } from "./services/observation_source_label.js";
|
|
40
|
+
import { isFilesystemLocalToCaller, buildFilePathServerLocalError, decodeFileContent, FileInputError, } from "./services/file_input_diagnostics.js";
|
|
37
41
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
38
42
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
39
43
|
import { NeotomaServer } from "./server.js";
|
|
@@ -1517,6 +1521,10 @@ app.use(aauthVerify({
|
|
|
1517
1521
|
// may nest a more specific `runWithRequestContext` — nested scopes
|
|
1518
1522
|
// shadow outer ones exactly as intended.
|
|
1519
1523
|
app.use(attributionContext());
|
|
1524
|
+
// Bind a per-request AbortSignal so DB reads stop consuming a reader-pool slot
|
|
1525
|
+
// when the client hangs up (#2217). Reads only — a write must not be abandoned
|
|
1526
|
+
// half-applied. No-op on backends without a worker pool.
|
|
1527
|
+
app.use(dbAbortContext());
|
|
1520
1528
|
// Stronger AAuth Admission plan: resolve verified AAuth identities to
|
|
1521
1529
|
// `agent_grant` entities and stamp `req.aauthAdmission` /
|
|
1522
1530
|
// `req.authenticatedUserId`. Runs after attributionContext so the
|
|
@@ -2024,6 +2032,10 @@ function sendValidationError(res, issues) {
|
|
|
2024
2032
|
}));
|
|
2025
2033
|
}
|
|
2026
2034
|
// Public health endpoint (no auth)
|
|
2035
|
+
//
|
|
2036
|
+
// LIVENESS ONLY. This reads package.json off disk and never touches the
|
|
2037
|
+
// database, so it returns 200 throughout a total read outage. Do not use it as
|
|
2038
|
+
// a Fly health check or as a watchdog's healthy verdict — use /ready below.
|
|
2027
2039
|
app.get("/health", (_req, res) => {
|
|
2028
2040
|
let version = "0.0.0";
|
|
2029
2041
|
try {
|
|
@@ -2036,6 +2048,21 @@ app.get("/health", (_req, res) => {
|
|
|
2036
2048
|
}
|
|
2037
2049
|
return res.json({ ok: true, version });
|
|
2038
2050
|
});
|
|
2051
|
+
// Public readiness endpoint (no auth). This is what Fly's health check hits —
|
|
2052
|
+
// see the [[http_service.checks]] block in fly.toml.
|
|
2053
|
+
//
|
|
2054
|
+
// Unlike /health above, it exercises the real read path and returns 503 when
|
|
2055
|
+
// the database is unreachable, erroring, or too slow. See
|
|
2056
|
+
// src/services/readiness.ts for exactly what a passing result does and does
|
|
2057
|
+
// not prove.
|
|
2058
|
+
app.get("/ready", async (_req, res) => {
|
|
2059
|
+
const result = await probeReadiness(db);
|
|
2060
|
+
if (!result.ok) {
|
|
2061
|
+
logger.warn(`[Ready] database probe failed after ${result.latency_ms}ms: ${result.error}`);
|
|
2062
|
+
return res.status(503).json(result);
|
|
2063
|
+
}
|
|
2064
|
+
return res.json(result);
|
|
2065
|
+
});
|
|
2039
2066
|
// ============================================================================
|
|
2040
2067
|
// Sandbox endpoints
|
|
2041
2068
|
//
|
|
@@ -2467,6 +2494,24 @@ app.get("/mcp/oauth/google/callback", async (req, res) => {
|
|
|
2467
2494
|
return failWith(error?.message ?? "Google sign-in failed.");
|
|
2468
2495
|
}
|
|
2469
2496
|
});
|
|
2497
|
+
/**
|
|
2498
|
+
* Send an authorize-endpoint refusal as plain text.
|
|
2499
|
+
*
|
|
2500
|
+
* `res.send(string)` defaults to `text/html`, which makes the body an HTML sink.
|
|
2501
|
+
* That matters here because this endpoint is reached by a BROWSER, before any
|
|
2502
|
+
* authentication, with a `redirect_uri` supplied by whoever crafted the link —
|
|
2503
|
+
* so anything echoed from the request into an HTML body would execute on this
|
|
2504
|
+
* instance's own origin. Setting `text/plain` removes the sink rather than
|
|
2505
|
+
* filtering it, which is what makes it safe to name the rejected redirect_uri
|
|
2506
|
+
* in the body below.
|
|
2507
|
+
*
|
|
2508
|
+
* Plain text (rather than the canonical JSON ErrorEnvelope) is also the right
|
|
2509
|
+
* shape for the reader: this renders in a browser address bar mid-redirect, for
|
|
2510
|
+
* a human. Declared as `text/plain` in openapi.yaml under `mcpOAuthAuthorize`.
|
|
2511
|
+
*/
|
|
2512
|
+
function sendAuthorizeRefusal(res, status, message) {
|
|
2513
|
+
return res.status(status).type("text/plain").send(message);
|
|
2514
|
+
}
|
|
2470
2515
|
// RFC 8414 authorization endpoint (GET) for Cursor and other OAuth clients
|
|
2471
2516
|
app.get("/mcp/oauth/authorize", async (req, res) => {
|
|
2472
2517
|
try {
|
|
@@ -2485,11 +2530,11 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
|
|
|
2485
2530
|
});
|
|
2486
2531
|
if (!redirect_uri) {
|
|
2487
2532
|
logger.warn("[MCP OAuth] Authorize rejected: missing redirect_uri");
|
|
2488
|
-
return res
|
|
2533
|
+
return sendAuthorizeRefusal(res, 400, "redirect_uri is required");
|
|
2489
2534
|
}
|
|
2490
2535
|
if (!state) {
|
|
2491
2536
|
logger.warn("[MCP OAuth] Authorize rejected: missing state");
|
|
2492
|
-
return res
|
|
2537
|
+
return sendAuthorizeRefusal(res, 400, "state is required");
|
|
2493
2538
|
}
|
|
2494
2539
|
const isOpenAiCustomGptRedirect = redirect_uri &&
|
|
2495
2540
|
(redirect_uri.includes("chatgpt.com") || redirect_uri.includes("chat.openai.com"));
|
|
@@ -2498,7 +2543,7 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
|
|
|
2498
2543
|
logger.warn("[MCP OAuth] Authorize rejected: missing PKCE for non-OpenAI redirect", {
|
|
2499
2544
|
redirect_uri: sanitizeRedirectUriForLog(redirect_uri),
|
|
2500
2545
|
});
|
|
2501
|
-
return res
|
|
2546
|
+
return sendAuthorizeRefusal(res, 400, "code_challenge and code_challenge_method=S256 are required");
|
|
2502
2547
|
}
|
|
2503
2548
|
if (!hasPkce && isOpenAiCustomGptRedirect) {
|
|
2504
2549
|
// Allow OAuth without client PKCE for OpenAI Custom GPT only (weaker security; see docs).
|
|
@@ -2509,9 +2554,7 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
|
|
|
2509
2554
|
return res.redirect(`/mcp/oauth/key-auth?next=${encodeURIComponent(nextPath)}`);
|
|
2510
2555
|
}
|
|
2511
2556
|
if (dev_stub === "1" || dev_stub === "true") {
|
|
2512
|
-
return res
|
|
2513
|
-
.status(400)
|
|
2514
|
-
.send("dev_stub is disabled. OAuth requires key authentication via /mcp/oauth/key-auth.");
|
|
2557
|
+
return sendAuthorizeRefusal(res, 400, "dev_stub is disabled. OAuth requires key authentication via /mcp/oauth/key-auth.");
|
|
2515
2558
|
}
|
|
2516
2559
|
if (config.storageBackend === "local") {
|
|
2517
2560
|
// When reached via tunnel, only allow redirect_uri to localhost, app schemes, or OpenAI Custom GPT
|
|
@@ -2521,12 +2564,31 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
|
|
|
2521
2564
|
// proxy) so an Inspector callback on this instance's own origin is allowed.
|
|
2522
2565
|
const selfHost = req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.get("host");
|
|
2523
2566
|
if (!isRedirectUriAllowedForTunnel(redirect_uri, selfHost)) {
|
|
2567
|
+
// Name the config variable and whether it is even set: an operator who
|
|
2568
|
+
// has configured a callback and still gets refused otherwise has no way
|
|
2569
|
+
// to tell a typo from an unset variable from an exact-match miss.
|
|
2570
|
+
const trustedCount = config.oauthTrustedCallbackUrls.length;
|
|
2571
|
+
const sanitizedRedirectUri = sanitizeRedirectUriForLog(redirect_uri);
|
|
2524
2572
|
logger.warn("[MCP OAuth] Authorize rejected: redirect_uri not allowed for tunnel", {
|
|
2525
|
-
redirect_uri:
|
|
2573
|
+
redirect_uri: sanitizedRedirectUri,
|
|
2574
|
+
trusted_callback_urls_configured: trustedCount,
|
|
2526
2575
|
});
|
|
2527
|
-
return res
|
|
2528
|
-
.
|
|
2529
|
-
|
|
2576
|
+
return sendAuthorizeRefusal(res, 400, "redirect_uri is not allowed when connecting via a tunnel. Use cursor://, localhost, loopback, or trusted callback URLs (OpenAI/Claude). " +
|
|
2577
|
+
// Echo what the server actually compared. An operator on a hosted
|
|
2578
|
+
// deploy has no log access, and every likely failure here is a
|
|
2579
|
+
// NEAR miss (trailing slash, port, case, a dot segment that
|
|
2580
|
+
// resolved) — indistinguishable from a typo or a client-side bug
|
|
2581
|
+
// without seeing the value the server saw. Safe to echo because
|
|
2582
|
+
// this response is text/plain (see sendAuthorizeRefusal) and
|
|
2583
|
+
// because the value is sanitized: scheme, host and path only,
|
|
2584
|
+
// with query and fragment — where the code and state live —
|
|
2585
|
+
// already stripped.
|
|
2586
|
+
(sanitizedRedirectUri
|
|
2587
|
+
? `The server compared: ${sanitizedRedirectUri} (query and fragment ignored). `
|
|
2588
|
+
: "") +
|
|
2589
|
+
(trustedCount > 0
|
|
2590
|
+
? `This instance has ${trustedCount} operator-configured callback URL(s) in NEOTOMA_OAUTH_TRUSTED_CALLBACK_URLS; none matched. That list is matched on the EXACT full callback URL: scheme, host, port and path must all agree. The host is compared case-insensitively and the path case-sensitively; a trailing slash and a default port (:443) are insignificant; query and fragment are ignored; and plaintext http: is only honoured for loopback hosts.`
|
|
2591
|
+
: "To trust a self-hosted app's callback, set NEOTOMA_OAUTH_TRUSTED_CALLBACK_URLS to its exact full callback URL (for example https://app.example.com/auth/callback)."));
|
|
2530
2592
|
}
|
|
2531
2593
|
}
|
|
2532
2594
|
const { randomUUID } = await import("node:crypto");
|
|
@@ -2581,7 +2643,7 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
|
|
|
2581
2643
|
}
|
|
2582
2644
|
catch (error) {
|
|
2583
2645
|
logError("MCPOAuthAuthorize", req, error);
|
|
2584
|
-
return res
|
|
2646
|
+
return sendAuthorizeRefusal(res, 500, error.message ?? "Authorization failed");
|
|
2585
2647
|
}
|
|
2586
2648
|
});
|
|
2587
2649
|
// Local OAuth login page (local backend only)
|
|
@@ -2919,10 +2981,23 @@ function explicitGuestAccessTokenFromRequest(req) {
|
|
|
2919
2981
|
}
|
|
2920
2982
|
return undefined;
|
|
2921
2983
|
}
|
|
2984
|
+
/**
|
|
2985
|
+
* Static path segments under `/entities/` that are routes in their own right,
|
|
2986
|
+
* not entity ids. The `/entities/:id` family of guest-eligible reads must never
|
|
2987
|
+
* treat these as an id (issue #2208): `/entities/duplicates` matched the
|
|
2988
|
+
* `:id`-shaped guest regex below, so a guest principal could be stamped onto a
|
|
2989
|
+
* route whose handler calls `getAuthenticatedUserId()` and 500s.
|
|
2990
|
+
*
|
|
2991
|
+
* Keep in sync with the `app.get("/entities/<segment>")` registrations. The
|
|
2992
|
+
* boot-time assertion in `assertNoShadowedRoutes()` fails the process if a new
|
|
2993
|
+
* static `/entities/*` route is added without being registered ahead of
|
|
2994
|
+
* `/entities/:id`, which is the other half of this invariant.
|
|
2995
|
+
*/
|
|
2996
|
+
export const RESERVED_ENTITY_PATH_SEGMENTS = new Set(["duplicates", "merge", "split", "query"]);
|
|
2922
2997
|
/** Exported for unit tests: routes where guest (AAuth / guest token) may be stamped before handlers run. */
|
|
2923
2998
|
export function routeAcceptsGuestPrincipal(req) {
|
|
2924
2999
|
const path = req.path;
|
|
2925
|
-
if (
|
|
3000
|
+
if (req.method === "POST" &&
|
|
2926
3001
|
(path === "/issues/submit" ||
|
|
2927
3002
|
path === "/api/issues/submit" ||
|
|
2928
3003
|
path === "/issues/status" ||
|
|
@@ -2932,12 +3007,19 @@ export function routeAcceptsGuestPrincipal(req) {
|
|
|
2932
3007
|
path === "/subscribe" ||
|
|
2933
3008
|
path === "/unsubscribe" ||
|
|
2934
3009
|
path === "/list_subscriptions" ||
|
|
2935
|
-
path === "/get_subscription_status"))
|
|
2936
|
-
(req.method === "GET" &&
|
|
2937
|
-
(/^\/entities\/[^/]+(?:\/(?:observations|relationships|html))?$/.test(path) ||
|
|
2938
|
-
path === "/events/stream"))) {
|
|
3010
|
+
path === "/get_subscription_status")) {
|
|
2939
3011
|
return true;
|
|
2940
3012
|
}
|
|
3013
|
+
if (req.method === "GET") {
|
|
3014
|
+
if (path === "/events/stream")
|
|
3015
|
+
return true;
|
|
3016
|
+
const match = /^\/entities\/([^/]+)(?:\/(?:observations|relationships|html))?$/.exec(path);
|
|
3017
|
+
if (!match)
|
|
3018
|
+
return false;
|
|
3019
|
+
// Fail closed: a reserved static segment is a route, not an entity id, so it
|
|
3020
|
+
// is not guest-eligible unless it is explicitly listed as such above.
|
|
3021
|
+
return !RESERVED_ENTITY_PATH_SEGMENTS.has(match[1]);
|
|
3022
|
+
}
|
|
2941
3023
|
return false;
|
|
2942
3024
|
}
|
|
2943
3025
|
/**
|
|
@@ -3262,7 +3344,8 @@ app.use(async (req, res, next) => {
|
|
|
3262
3344
|
(req.method === "GET" &&
|
|
3263
3345
|
(req.path === "/openapi.yaml" ||
|
|
3264
3346
|
req.path === "/openapi_actions.yaml" ||
|
|
3265
|
-
req.path === "/health"
|
|
3347
|
+
req.path === "/health" ||
|
|
3348
|
+
req.path === "/ready")) ||
|
|
3266
3349
|
(req.method === "POST" && req.path === "/auth/dev-signin")) {
|
|
3267
3350
|
return next();
|
|
3268
3351
|
}
|
|
@@ -3846,6 +3929,53 @@ app.all("/entities", (req, res) => {
|
|
|
3846
3929
|
supported: ["GET /entities", "POST /entities/query"],
|
|
3847
3930
|
});
|
|
3848
3931
|
});
|
|
3932
|
+
// IMPORTANT (issue #2208): this static route MUST stay registered before
|
|
3933
|
+
// `GET /entities/:id` below. Express matches first-wins, so a param route
|
|
3934
|
+
// registered earlier would capture `id = "duplicates"` and 404. The
|
|
3935
|
+
// boot-time assertion in assertNoShadowedRoutes() enforces this invariant.
|
|
3936
|
+
// GET /entities/duplicates - List candidate duplicate entity pairs (R5).
|
|
3937
|
+
// Read-only fuzzy post-hoc detector. Never auto-merges. Hands off to
|
|
3938
|
+
// /entities/merge once an operator or agent confirms a pair.
|
|
3939
|
+
app.get("/entities/duplicates", async (req, res) => {
|
|
3940
|
+
try {
|
|
3941
|
+
const entityType = typeof req.query.entity_type === "string" ? req.query.entity_type : undefined;
|
|
3942
|
+
if (!entityType) {
|
|
3943
|
+
return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "entity_type query parameter is required");
|
|
3944
|
+
}
|
|
3945
|
+
const providedUserId = typeof req.query.user_id === "string" ? req.query.user_id : undefined;
|
|
3946
|
+
const authenticatedUserId = await getAuthenticatedUserId(req, providedUserId);
|
|
3947
|
+
if (providedUserId && providedUserId !== authenticatedUserId) {
|
|
3948
|
+
return sendError(res, 403, "FORBIDDEN", "user_id does not match authenticated user.");
|
|
3949
|
+
}
|
|
3950
|
+
const thresholdRaw = typeof req.query.threshold === "string" ? req.query.threshold : undefined;
|
|
3951
|
+
const limitRaw = typeof req.query.limit === "string" ? req.query.limit : undefined;
|
|
3952
|
+
const threshold = thresholdRaw ? Number(thresholdRaw) : undefined;
|
|
3953
|
+
if (threshold !== undefined && (Number.isNaN(threshold) || threshold <= 0 || threshold > 1)) {
|
|
3954
|
+
return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "threshold must be a number in (0, 1]");
|
|
3955
|
+
}
|
|
3956
|
+
const limit = limitRaw ? Number(limitRaw) : undefined;
|
|
3957
|
+
if (limit !== undefined && (!Number.isFinite(limit) || limit < 1 || limit > 200)) {
|
|
3958
|
+
return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "limit must be an integer in [1, 200]");
|
|
3959
|
+
}
|
|
3960
|
+
const { findDuplicateCandidates } = await import("./services/duplicate_detection.js");
|
|
3961
|
+
const candidates = await findDuplicateCandidates({
|
|
3962
|
+
entityType,
|
|
3963
|
+
userId: authenticatedUserId,
|
|
3964
|
+
threshold,
|
|
3965
|
+
limit,
|
|
3966
|
+
});
|
|
3967
|
+
return res.json({
|
|
3968
|
+
candidates,
|
|
3969
|
+
entity_type: entityType,
|
|
3970
|
+
threshold: threshold ?? null,
|
|
3971
|
+
});
|
|
3972
|
+
}
|
|
3973
|
+
catch (error) {
|
|
3974
|
+
logError("APIError:entities_duplicates", req, error);
|
|
3975
|
+
const message = error instanceof Error ? error.message : "Failed to list potential duplicates";
|
|
3976
|
+
return sendError(res, 500, "DB_QUERY_FAILED", message);
|
|
3977
|
+
}
|
|
3978
|
+
});
|
|
3849
3979
|
// GET /api/entities/:id - Get entity detail with snapshot and provenance (FU-601)
|
|
3850
3980
|
// REQUIRES AUTHENTICATION - verifies entity belongs to authenticated user
|
|
3851
3981
|
app.get("/entities/:id", async (req, res) => {
|
|
@@ -4362,27 +4492,30 @@ app.get("/schemas/:entity_type", async (req, res) => {
|
|
|
4362
4492
|
const entityType = decodeURIComponent(req.params.entity_type);
|
|
4363
4493
|
const { SchemaRegistryService } = await import("./services/schema_registry.js");
|
|
4364
4494
|
const schemaRegistry = new SchemaRegistryService();
|
|
4365
|
-
//
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4495
|
+
// SECURITY (GHSA-f48j-993h-g6qr): `user_id` selects the schema SCOPE that
|
|
4496
|
+
// loadActiveSchema reads — a user-scoped row shadows the global one — so it
|
|
4497
|
+
// carries the safety meaning for this route and must be validated against
|
|
4498
|
+
// the authenticated principal, never used as supplied.
|
|
4499
|
+
//
|
|
4500
|
+
// This route previously initialised `userId` straight from
|
|
4501
|
+
// `req.query.user_id` and ran its own Bearer/session handling, so the query
|
|
4502
|
+
// value became the scope rather than a request for one. That bespoke block
|
|
4503
|
+
// is removed in favour of the shared guard its sibling GET /schemas already
|
|
4504
|
+
// uses. Nothing is lost by removing it: the app-wide auth middleware runs
|
|
4505
|
+
// ahead of this route (this path is not on the public allowlist, which is
|
|
4506
|
+
// only /openapi*.yaml, /health and /ready), validates Ed25519 bearers
|
|
4507
|
+
// *including the request signature*, falls through to the same
|
|
4508
|
+
// validateSessionToken for session bearers, stamps the principal, and 401s
|
|
4509
|
+
// an unresolvable token. The route-level copy did strictly less — no
|
|
4510
|
+
// signature verification, and a silent fall-through that kept the
|
|
4511
|
+
// caller-supplied id when validation failed.
|
|
4512
|
+
//
|
|
4513
|
+
// Legitimate callers are unaffected: the CLI's optional --user-id and the
|
|
4514
|
+
// eval harness pass nothing by default, so the principal resolves itself,
|
|
4515
|
+
// and getAuthenticatedUserId still honours the local-dev override for CLI
|
|
4516
|
+
// and dev flows. Built-in/code-defined schema discovery is unchanged — it
|
|
4517
|
+
// is the fallback below, reached the same way once the scope is resolved.
|
|
4518
|
+
const userId = await getAuthenticatedUserId(req, req.query.user_id);
|
|
4386
4519
|
// Try to load active schema (global or user-specific), then fallback to code-defined schemas
|
|
4387
4520
|
let schema = await schemaRegistry.loadActiveSchema(entityType, userId);
|
|
4388
4521
|
if (!schema) {
|
|
@@ -4407,9 +4540,11 @@ app.get("/schemas/:entity_type", async (req, res) => {
|
|
|
4407
4540
|
return res.json(schema);
|
|
4408
4541
|
}
|
|
4409
4542
|
catch (error) {
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4543
|
+
// Routed through handleApiError so a rejected user_id surfaces as 403 and an
|
|
4544
|
+
// unresolved principal as 401 — the same mapping GET /schemas gets. The
|
|
4545
|
+
// previous bespoke catch turned every failure into a 500, which would have
|
|
4546
|
+
// reported the guard's refusal as a server fault.
|
|
4547
|
+
return handleApiError(req, res, error, "Failed to get schema", "DB_QUERY_FAILED", "APIError:schema_detail");
|
|
4413
4548
|
}
|
|
4414
4549
|
});
|
|
4415
4550
|
/**
|
|
@@ -6862,7 +6997,9 @@ export async function storeStructuredForApi(params) {
|
|
|
6862
6997
|
// boundary" section in src/services/instance_policy.ts's docblock.
|
|
6863
6998
|
async function storeUnstructuredForApi(params) {
|
|
6864
6999
|
const { fileContent, fileBuffer, filePath, mimeType, idempotencyKey, originalFilename, sourceType, userId, storageMode = "inline", } = params;
|
|
6865
|
-
|
|
7000
|
+
// decodeFileContent rejects non-base64 instead of letting Node discard the
|
|
7001
|
+
// invalid characters and store corrupted bytes (#2325).
|
|
7002
|
+
const resolvedFileBuffer = fileBuffer ?? (fileContent !== undefined ? decodeFileContent(fileContent) : undefined);
|
|
6866
7003
|
if (!resolvedFileBuffer && storageMode !== "reference") {
|
|
6867
7004
|
throw new Error("fileContent or fileBuffer is required for inline storage");
|
|
6868
7005
|
}
|
|
@@ -6939,9 +7076,24 @@ async function handleStorePost(req, res) {
|
|
|
6939
7076
|
let originalFilename = parsed.data.original_filename;
|
|
6940
7077
|
let resolvedFileBuffer;
|
|
6941
7078
|
if (hasFilePath) {
|
|
7079
|
+
// `file_path` resolves against *this* process's filesystem. On a remote
|
|
7080
|
+
// instance it cannot mean the caller's disk, and the old failure here
|
|
7081
|
+
// was a bare Node ENOENT — worse than MCP's, which at least named the
|
|
7082
|
+
// path. Both transports now fail identically (#2325).
|
|
7083
|
+
if (!isFilesystemLocalToCaller()) {
|
|
7084
|
+
const serverLocal = buildFilePathServerLocalError(parsed.data.file_path);
|
|
7085
|
+
sendError(res, 400, serverLocal.code, serverLocal.message, serverLocal.details);
|
|
7086
|
+
return null;
|
|
7087
|
+
}
|
|
6942
7088
|
const resolvedPath = path.isAbsolute(parsed.data.file_path)
|
|
6943
7089
|
? parsed.data.file_path
|
|
6944
7090
|
: path.resolve(process.cwd(), parsed.data.file_path);
|
|
7091
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
7092
|
+
sendError(res, 400, "ERR_FILE_NOT_FOUND", `File not found: ${resolvedPath}`, {
|
|
7093
|
+
file_path: parsed.data.file_path,
|
|
7094
|
+
});
|
|
7095
|
+
return null;
|
|
7096
|
+
}
|
|
6945
7097
|
if (parsed.data.source_storage === "reference") {
|
|
6946
7098
|
// For reference mode, don't read the file buffer, just use the path
|
|
6947
7099
|
if (!mimeType) {
|
|
@@ -7038,6 +7190,13 @@ async function handleStorePost(req, res) {
|
|
|
7038
7190
|
if (error instanceof Error && error.message.includes("Not authenticated")) {
|
|
7039
7191
|
return sendError(res, 401, "AUTH_REQUIRED", error.message);
|
|
7040
7192
|
}
|
|
7193
|
+
// A bad file input is the caller's payload, not a server fault: 400 with
|
|
7194
|
+
// the code, never the generic 500 below. Falling through would have made a
|
|
7195
|
+
// non-base64 `file_content` look like a server outage (#2325).
|
|
7196
|
+
if (error instanceof FileInputError) {
|
|
7197
|
+
logWarn("FileInputError:store", req, { code: error.code });
|
|
7198
|
+
return sendError(res, 400, error.code, error.message, error.details);
|
|
7199
|
+
}
|
|
7041
7200
|
const errCode = error && typeof error === "object" ? error.code : undefined;
|
|
7042
7201
|
if (errCode === "ERR_FORBIDDEN_ENTITY_TYPE" || errCode === "ERR_PLURAL_ENTITY_TYPE") {
|
|
7043
7202
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -7480,49 +7639,9 @@ app.post("/observations/query", async (req, res) => {
|
|
|
7480
7639
|
return sendError(res, 500, "DB_QUERY_FAILED", message);
|
|
7481
7640
|
}
|
|
7482
7641
|
});
|
|
7483
|
-
// GET /entities/duplicates
|
|
7484
|
-
//
|
|
7485
|
-
//
|
|
7486
|
-
app.get("/entities/duplicates", async (req, res) => {
|
|
7487
|
-
try {
|
|
7488
|
-
const entityType = typeof req.query.entity_type === "string" ? req.query.entity_type : undefined;
|
|
7489
|
-
if (!entityType) {
|
|
7490
|
-
return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "entity_type query parameter is required");
|
|
7491
|
-
}
|
|
7492
|
-
const providedUserId = typeof req.query.user_id === "string" ? req.query.user_id : undefined;
|
|
7493
|
-
const authenticatedUserId = await getAuthenticatedUserId(req, providedUserId);
|
|
7494
|
-
if (providedUserId && providedUserId !== authenticatedUserId) {
|
|
7495
|
-
return sendError(res, 403, "FORBIDDEN", "user_id does not match authenticated user.");
|
|
7496
|
-
}
|
|
7497
|
-
const thresholdRaw = typeof req.query.threshold === "string" ? req.query.threshold : undefined;
|
|
7498
|
-
const limitRaw = typeof req.query.limit === "string" ? req.query.limit : undefined;
|
|
7499
|
-
const threshold = thresholdRaw ? Number(thresholdRaw) : undefined;
|
|
7500
|
-
if (threshold !== undefined && (Number.isNaN(threshold) || threshold <= 0 || threshold > 1)) {
|
|
7501
|
-
return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "threshold must be a number in (0, 1]");
|
|
7502
|
-
}
|
|
7503
|
-
const limit = limitRaw ? Number(limitRaw) : undefined;
|
|
7504
|
-
if (limit !== undefined && (!Number.isFinite(limit) || limit < 1 || limit > 200)) {
|
|
7505
|
-
return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "limit must be an integer in [1, 200]");
|
|
7506
|
-
}
|
|
7507
|
-
const { findDuplicateCandidates } = await import("./services/duplicate_detection.js");
|
|
7508
|
-
const candidates = await findDuplicateCandidates({
|
|
7509
|
-
entityType,
|
|
7510
|
-
userId: authenticatedUserId,
|
|
7511
|
-
threshold,
|
|
7512
|
-
limit,
|
|
7513
|
-
});
|
|
7514
|
-
return res.json({
|
|
7515
|
-
candidates,
|
|
7516
|
-
entity_type: entityType,
|
|
7517
|
-
threshold: threshold ?? null,
|
|
7518
|
-
});
|
|
7519
|
-
}
|
|
7520
|
-
catch (error) {
|
|
7521
|
-
logError("APIError:entities_duplicates", req, error);
|
|
7522
|
-
const message = error instanceof Error ? error.message : "Failed to list potential duplicates";
|
|
7523
|
-
return sendError(res, 500, "DB_QUERY_FAILED", message);
|
|
7524
|
-
}
|
|
7525
|
-
});
|
|
7642
|
+
// NOTE: `GET /entities/duplicates` is registered earlier in this file,
|
|
7643
|
+
// immediately before `GET /entities/:id`. A static path must be registered
|
|
7644
|
+
// before any param route that would also match it (issue #2208).
|
|
7526
7645
|
// POST /api/entities/merge - Merge duplicate entities
|
|
7527
7646
|
// REQUIRES AUTHENTICATION - validates user_id matches authenticated user and entities belong to user
|
|
7528
7647
|
app.post("/entities/merge", async (req, res) => {
|
|
@@ -9484,18 +9603,27 @@ app.post("/health_check_snapshots", async (req, res) => {
|
|
|
9484
9603
|
}
|
|
9485
9604
|
}
|
|
9486
9605
|
let fixedCount = 0;
|
|
9606
|
+
let redirectedCount = 0;
|
|
9487
9607
|
if (auto_fix && staleEntities.length > 0) {
|
|
9488
9608
|
// Recompute snapshots for stale entities using observationReducer
|
|
9489
9609
|
const { observationReducer } = await import("./reducers/observation_reducer.js");
|
|
9610
|
+
const { resolveOwnedObservations } = await import("./services/attachment_resolution.js");
|
|
9490
9611
|
for (const entity of staleEntities) {
|
|
9491
9612
|
try {
|
|
9492
|
-
//
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
|
|
9613
|
+
// #2343: the observations ATTACHED to this entity, resolved through
|
|
9614
|
+
// the declared layer, and null when the id is redirected — a
|
|
9615
|
+
// tombstone owns no snapshot, so auto-fix must skip it rather than
|
|
9616
|
+
// upsert the survivor's snapshot under the tombstone's id.
|
|
9617
|
+
// `null` scope, deliberately: this endpoint's observation fetch was
|
|
9618
|
+
// unscoped before #2343, and adding a user_id filter here would
|
|
9619
|
+
// change which rows the reducer sees. Routing the fetch through the
|
|
9620
|
+
// seam is this PR's job; tightening this endpoint's tenancy is not.
|
|
9621
|
+
const observations = await resolveOwnedObservations(entity.entity_id, null);
|
|
9622
|
+
if (observations === null) {
|
|
9623
|
+
redirectedCount++;
|
|
9624
|
+
continue;
|
|
9625
|
+
}
|
|
9626
|
+
if (observations.length === 0)
|
|
9499
9627
|
continue;
|
|
9500
9628
|
// Recompute snapshot
|
|
9501
9629
|
const newSnapshot = await observationReducer.computeSnapshot(entity.entity_id, observations);
|
|
@@ -9529,11 +9657,15 @@ app.post("/health_check_snapshots", async (req, res) => {
|
|
|
9529
9657
|
message: staleEntities.length === 0
|
|
9530
9658
|
? "All snapshots healthy"
|
|
9531
9659
|
: auto_fix
|
|
9532
|
-
? `Found ${staleEntities.length} stale snapshots, fixed ${fixedCount}`
|
|
9660
|
+
? `Found ${staleEntities.length} stale snapshots, fixed ${fixedCount}` +
|
|
9661
|
+
(redirectedCount > 0
|
|
9662
|
+
? `, skipped ${redirectedCount} redirected (merged-away) id(s)`
|
|
9663
|
+
: "")
|
|
9533
9664
|
: `Found ${staleEntities.length} stale snapshots`,
|
|
9534
9665
|
checked: staleSnapshots?.length || 0,
|
|
9535
9666
|
stale: staleEntities.length,
|
|
9536
9667
|
fixed: auto_fix ? fixedCount : undefined,
|
|
9668
|
+
skipped_redirected: auto_fix ? redirectedCount : undefined,
|
|
9537
9669
|
stale_snapshots: staleEntities,
|
|
9538
9670
|
});
|
|
9539
9671
|
}
|
|
@@ -9747,9 +9879,20 @@ function emitSandboxBootBanner(mode, reason, shouldRefuseBoot, refusePolicy, pro
|
|
|
9747
9879
|
});
|
|
9748
9880
|
process.stderr.write(lines.join("\n"));
|
|
9749
9881
|
}
|
|
9882
|
+
/**
|
|
9883
|
+
* Fail the boot if any static route is unreachable because an earlier param
|
|
9884
|
+
* route matches it first (issue #2208). Runs after every route registration
|
|
9885
|
+
* and before the process binds a port: an unreachable route table is not a
|
|
9886
|
+
* runnable state, and a warning is what let `GET /entities/duplicates` ship
|
|
9887
|
+
* broken for months.
|
|
9888
|
+
*/
|
|
9889
|
+
function assertRouteTableIsReachable() {
|
|
9890
|
+
assertNoShadowedRoutes(app);
|
|
9891
|
+
}
|
|
9750
9892
|
/** Try to bind on a port; resolves with server and port, or rejects on error (e.g. EADDRINUSE). */
|
|
9751
9893
|
function tryListen(port) {
|
|
9752
9894
|
return new Promise((resolve, reject) => {
|
|
9895
|
+
assertRouteTableIsReachable();
|
|
9753
9896
|
const server = app.listen(port, () => {
|
|
9754
9897
|
// When `port === 0` the OS assigns an ephemeral port. We must report
|
|
9755
9898
|
// the actually-bound port back to callers (the eval harness's
|