From 804d8afbac9b0e0969489a8195ef02f3ab189983 Mon Sep 17 00:00:00 2001 From: Andrew Hariri <31301117+hariria@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:12:22 -0700 Subject: [PATCH 1/6] Add bounded search response replay and verified Glama discovery --- docs/agent-readiness.md | 37 +- proa_marketing/README.md | 6 + proa_marketing/src/agent/openapi.rs | 4 +- proa_marketing/src/agent_tests.rs | 204 ++++++++++- .../src/components/landing_shell.rs | 1 + proa_marketing/src/data/site.rs | 1 + proa_marketing/src/docs/api.mdx | 25 +- proa_marketing/src/endpoints/api.rs | 74 +++- proa_marketing/src/endpoints/api/replay.rs | 317 ++++++++++++++++++ proa_marketing/src/main.rs | 7 +- 10 files changed, 653 insertions(+), 23 deletions(-) create mode 100644 proa_marketing/src/endpoints/api/replay.rs diff --git a/docs/agent-readiness.md b/docs/agent-readiness.md index 0820904..9765183 100644 --- a/docs/agent-readiness.md +++ b/docs/agent-readiness.md @@ -38,7 +38,7 @@ and consistent HTML/Markdown discovery links. | npm | Created the free `proa-labs` organization and published [@proa-labs/docs 0.1.0](https://www.npmjs.com/package/@proa-labs/docs). A fresh registry installation successfully searched the live API. | Complete. | | PyPI | Published [proa-docs 0.1.1](https://pypi.org/project/proa-docs/0.1.1/) as `proa`, with wheel and source distribution. A fresh registry installation searched the live API. Both temporary release tokens were revoked and deletion confirmed by PyPI. | Complete. | | Smithery | Created the Proa Labs workspace (`proa-labs`); attempted `proa-docs` registration. The final step returned 403: "Updating servers has been removed." No listing was confirmed. | Resume when Smithery supports publication, or resolve through its support process. | -| Glama | Submitted “Proa documentation” with hosted endpoint `https://proa.so/mcp`. Glama's email receipt confirms it is pending review. | Wait for approval, verify the public listing, then add its exact URL to the API guide. | +| Glama | The [Proa documentation connector](https://glama.ai/mcp/connectors/so.proa/proa-documentation) is publicly listed. Verified October 29: Healthy, Streamable HTTP, endpoint `https://proa.so/mcp`, and both read-only tools. | This follow-up adds the exact listing URL to the API guide and shared homepage HTML/Markdown footer; recheck Ora after deployment. | | Official MCP Registry | Published [io.github.proa-web/docs 1.0.0](https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.proa-web%2Fdocs) using the Proa GitHub identity. The registry reports it active and the API guide's reciprocal link is deployed. | Ora still does not recognize the listing. | | PulseMCP | Site states that new submissions are paused. | Resume when submissions reopen; no submission was made. | | MCP.so | Submission flow requires a $39 one-time payment. | Optional paid submission needs a spending decision; no payment or submission was made. | @@ -57,6 +57,9 @@ name-search warning. Search providers and sampled queries can differ. ## Follow-up fixes +PR #58 merged on October 29 at 00:00 UTC. Its normal production deployment +must finish before the canonical scan can reflect these homepage changes. + - Put the API guide before subsection indexes in the shared homepage footer. The deployed scan inspected only `/docs`, `/docs/llms.txt`, and `/docs/framework/llms.txt`, reducing public API documentation to 2/3. @@ -73,6 +76,24 @@ both changed checks under contract 1.37.0: public API documentation **3/3** update the canonical production score. All 161 marketing tests, Proa format and lint checks, and the render-allocation lint passed. +The next follow-up adds a functional optional `Idempotency-Key` to a typed +JSON `POST /api/v1/search`. A successful request is retained for fifteen +minutes on the serving instance. Matching retries replay its JSON body; +changed parameters with an active key return 422. The bounded cache rejects +new keys with 503 when full and is not shared across replicas or restarts. +This adds one operation to each OpenAPI document: five JSON operations in the +canonical contract and eleven in the complete website contract. Existing GET, +MCP, SDK, and legacy search clients continue to work. + +Ora's live selected checks against this follow-up's temporary public preview +under contract 1.38.0 awarded idempotency **3/3**, response-schema coverage +**2/2**, and function-calling compatibility **2/2**. A direct public HTTP +probe also confirmed the first keyed response reports +`Idempotency-Replayed: false` and the matching retry reports `true`. +All 169 marketing tests passed, including replay, expiry, capacity, concurrent +requests, header validation, body limits, and typed errors. This preview check +does not update the canonical production score. + ## Deployment and rescan 1. Review and merge the follow-up PR. The current authorization permits @@ -88,13 +109,13 @@ and lint checks, and the render-allocation lint passed. ## Read-only service constraints -The documentation API, including legacy `POST /api/search`, only reads public -compiled content. It has no state-changing operation requiring an idempotency -key, no long-running operation requiring job polling, and no paid/live account -state requiring a separate sandbox. The API guide and canonical OpenAPI -description now state this explicitly. Creating artificial writes, jobs, or -credentials would make the API worse. If Ora still deducts those points, its -applicability assessment needs review. +The documentation API, including both POST search interfaces, only reads +public compiled content. JSON POST search now offers optional response replay +with real retention and conflict handling. There is still no long-running +operation requiring job polling or paid/live account state requiring a +separate sandbox. The API guide and canonical OpenAPI description state these +limits. Artificial writes, jobs, or credentials would make the API worse; +remaining applicability deductions need review by Ora. The report's generated integration narrative also describes API-key authentication, despite its own successful zero-auth probe. Recheck that claim diff --git a/proa_marketing/README.md b/proa_marketing/README.md index 262a97f..7ca08f8 100644 --- a/proa_marketing/README.md +++ b/proa_marketing/README.md @@ -39,6 +39,12 @@ read-only tools and paginated Markdown resources. `/mcp/server-card` describes the connection. No credentials or writes are exposed. API and MCP responses advertise an enforced shared per-instance budget of 600 requests per minute. +Search also accepts a JSON POST body. Its optional `Idempotency-Key` replays +successful responses for fifteen minutes on the serving instance, rejects a +key reused for different parameters, and bounds retained responses to 256 keys +and 8 MiB. Replicas and restarts do not share this cache. All searches remain +safe to repeat without a key; see the API guide for the exact replay contract. + The optional `/static/agent-tools.mjs` registers the search and retrieval operations with WebMCP. Its tests run with `node --test proa_marketing/tests-js/agent-tools.test.mjs`. HTTP and MCP tests diff --git a/proa_marketing/src/agent/openapi.rs b/proa_marketing/src/agent/openapi.rs index 6ff9678..28fbef3 100644 --- a/proa_marketing/src/agent/openapi.rs +++ b/proa_marketing/src/agent/openapi.rs @@ -24,10 +24,11 @@ use crate::pages::home; info( title = "Proa public documentation API", version = "1.0.0", - description = const_format::concatcp!("Public, synchronous, read-only Proa Rust framework documentation. Every operation is a GET and returns JSON. No authentication is required. Requests are safe to retry; there are no writes requiring Idempotency-Key or long-running jobs requiring 202/polling. The ", routes::API_V1, " prefix is the compatibility boundary: breaking changes receive a new prefix; deprecations are announced in ", routes::API_GUIDE, " before removal. Pagination cursors are opaque and valid only for the same result set and deployment. The complete website and legacy search contract is at ", routes::SITE_OPENAPI, ".") + description = const_format::concatcp!("Public, synchronous, read-only Proa Rust framework documentation. Operations return JSON using GET or JSON POST search. No authentication is required. POST search supports optional Idempotency-Key response replay for fifteen minutes on the serving instance, with bounded capacity; the cache is not shared between replicas or across restarts. All reads are safe to repeat. There are no long-running jobs requiring 202/polling. The ", routes::API_V1, " prefix is the compatibility boundary: breaking changes receive a new prefix; deprecations are announced in ", routes::API_GUIDE, " before removal. Pagination cursors are opaque and valid only for the same result set and deployment. The complete website and legacy search contract is at ", routes::SITE_OPENAPI, ".") ), paths( api::search_handler, + api::search_post_handler, api::list_handler, api::page_handler, agent_discovery::site_metadata @@ -44,6 +45,7 @@ struct DocumentationApi; info(title = "Proa website and legacy interfaces", version = "1.0.0"), paths( api::search_handler, + api::search_post_handler, api::list_handler, api::page_handler, search::handler, diff --git a/proa_marketing/src/agent_tests.rs b/proa_marketing/src/agent_tests.rs index dd68bfa..2a8e91e 100644 --- a/proa_marketing/src/agent_tests.rs +++ b/proa_marketing/src/agent_tests.rs @@ -96,6 +96,7 @@ async fn homepage_negotiates_markdown_and_keeps_html_for_browsers() { site::PYTHON_SDK_URL, site::TYPESCRIPT_SDK_URL, site::MCP_REGISTRY_URL, + site::MCP_GLAMA_URL, ] { assert!(footer.contains(&format!("href=\"{url}\""))); assert!(markdown.contains(&format!("]({url})"))); @@ -204,12 +205,42 @@ async fn tool_openapi_only_advertises_typed_json_reads() { serde_json::from_str(&fetch(&app, "/openapi.json", "application/json").await.2).unwrap(); let paths = spec["paths"].as_object().unwrap(); assert_eq!(paths.len(), 4); + let mut ids = std::collections::HashSet::new(); for (path, item) in paths { - let methods = item.as_object().unwrap(); - assert_eq!(methods.len(), 1, "only GET is part of the JSON API: {path}"); - let operation = &item["get"]; - assert!(operation["operationId"].is_string()); - assert!(operation["responses"]["200"]["content"]["application/json"]["schema"].is_object()); + for (method, operation) in item.as_object().unwrap() { + assert!(method == "get" || (path == "/api/v1/search" && method == "post")); + assert!(ids.insert(operation["operationId"].as_str().unwrap())); + assert!( + operation["responses"]["200"]["content"]["application/json"]["schema"].is_object() + ); + } + } + assert_eq!(ids.len(), 5); + let post = &paths["/api/v1/search"]["post"]; + let input_ref = post["requestBody"]["content"]["application/json"]["schema"]["$ref"] + .as_str() + .unwrap() + .strip_prefix('#') + .unwrap(); + let input = spec.pointer(input_ref).unwrap(); + assert_eq!(input["type"], "object"); + assert_eq!(input["required"], json!(["q"])); + assert_eq!(input["additionalProperties"], false); + assert_eq!(input["properties"]["q"]["maxLength"], 256); + assert_eq!(input["properties"]["limit"]["maximum"], 50); + let key = post["parameters"] + .as_array() + .unwrap() + .iter() + .find(|parameter| parameter["name"] == "Idempotency-Key") + .unwrap(); + assert_eq!(key["in"], "header"); + assert_eq!(key["required"], false); + assert!(key["schema"]["pattern"].is_string()); + for status in ["413", "415", "422", "503"] { + assert!( + post["responses"][status]["content"]["application/problem+json"]["schema"].is_object() + ); } assert!(paths.contains_key("/api/v1/search")); assert!(paths.contains_key("/api/v1/docs/page")); @@ -544,6 +575,7 @@ async fn generated_contracts_keep_operation_ids_errors_and_resolvable_schemas() check_refs(&spec, &spec); for (path, method, operation_id) in [ ("/api/v1/search", "get", "searchDocs"), + ("/api/v1/search", "post", "searchDocsPost"), ("/api/v1/docs", "get", "listDocs"), ("/api/v1/docs/page", "get", "getDoc"), ("/api/search", "get", "searchProaDocumentation"), @@ -718,6 +750,156 @@ async fn rest_search_pagination_retrieval_and_errors_match_the_contract() { } } +#[tokio::test] +async fn json_search_replays_successes_and_rejects_key_reuse() { + let app = super::app(); + let get = fetch(&app, "/api/v1/search?q=render&limit=2", "application/json") + .await + .2; + for (payload, key, replayed) in [ + (r#"{"q":"render","limit":2}"#, None, None), + ( + r#"{"q":"render","limit":2}"#, + Some("\"http-retry\""), + Some("false"), + ), + ( + r#"{ "limit": 2, "q": "render" }"#, + Some("\"http-retry\""), + Some("true"), + ), + ] { + let mut request = Request::builder() + .method(Method::POST) + .uri("/api/v1/search") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(key) = key { + request = request.header("Idempotency-Key", key); + } + let response = app + .clone() + .oneshot(request.body(Body::from(payload)).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("idempotency-replayed") + .map(|value| value.to_str().unwrap()), + replayed + ); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!(response.headers().contains_key("ratelimit")); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + assert_eq!(body.as_ref(), get.as_bytes()); + } + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/search") + .header(header::CONTENT_TYPE, "application/json") + .header("Idempotency-Key", "\"http-retry\"") + .body(Body::from(r#"{"q":"render","limit":3}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "application/problem+json" + ); + let problem: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap(); + assert_eq!(problem["code"], "idempotency_key_reused"); +} + +#[tokio::test] +async fn json_search_reports_typed_input_errors_and_does_not_cache_failures() { + let app = super::app(); + for (payload, media_type, key, status) in [ + ( + r#"{"q":"render"}"#.to_owned(), + "text/plain", + "\"input-tests\"", + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ), + ( + "{".to_owned(), + "application/json", + "\"input-tests\"", + StatusCode::BAD_REQUEST, + ), + ( + r#"{"q":"render","extra":true}"#.to_owned(), + "application/json", + "\"input-tests\"", + StatusCode::BAD_REQUEST, + ), + ( + r#"{"q":"render","limit":0}"#.to_owned(), + "application/json", + "\"input-tests\"", + StatusCode::BAD_REQUEST, + ), + ( + r#"{"q":"render","cursor":"invalid"}"#.to_owned(), + "application/json", + "\"input-tests\"", + StatusCode::BAD_REQUEST, + ), + ( + " ".repeat(16 * 1024 + 1), + "application/json", + "\"input-tests\"", + StatusCode::PAYLOAD_TOO_LARGE, + ), + ( + r#"{"q":"render"}"#.to_owned(), + "application/json", + "unquoted", + StatusCode::BAD_REQUEST, + ), + ( + r#"{"q":"render"}"#.to_owned(), + "application/json", + "\"input-tests\"", + StatusCode::OK, + ), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/search") + .header(header::CONTENT_TYPE, media_type) + .header("Idempotency-Key", key) + .body(Body::from(payload)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), status); + if status == StatusCode::OK { + assert_eq!(response.headers()["idempotency-replayed"], "false"); + } else { + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "application/problem+json" + ); + assert!(!response.headers().contains_key("idempotency-replayed")); + let problem: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()) + .unwrap(); + assert_eq!(problem["status"], status.as_u16()); + } + } +} + #[tokio::test] async fn missing_pages_preserve_404_with_useful_markdown() { let app = super::app(); @@ -761,6 +943,9 @@ async fn rest_method_errors_and_missing_routes_have_distinct_typed_responses() { ("/api/missing", StatusCode::NOT_FOUND), ] { for method in [Method::POST, Method::PUT, Method::DELETE] { + if path == "/api/v1/search" && method == Method::POST { + continue; + } let response = app .clone() .oneshot( @@ -778,7 +963,14 @@ async fn rest_method_errors_and_missing_routes_have_distinct_typed_responses() { "application/problem+json" ); if status == StatusCode::METHOD_NOT_ALLOWED { - assert_eq!(response.headers()[header::ALLOW], "GET,HEAD"); + let allowed = response.headers()[header::ALLOW].to_str().unwrap(); + let methods: std::collections::HashSet<_> = allowed.split(',').collect(); + let expected: std::collections::HashSet<_> = if path == "/api/v1/search" { + ["GET", "HEAD", "POST"].into_iter().collect() + } else { + ["GET", "HEAD"].into_iter().collect() + }; + assert_eq!(methods, expected); } let body = to_bytes(response.into_body(), 4096).await.unwrap(); let problem: Value = serde_json::from_slice(&body).unwrap(); diff --git a/proa_marketing/src/components/landing_shell.rs b/proa_marketing/src/components/landing_shell.rs index b187f8e..444a55b 100644 --- a/proa_marketing/src/components/landing_shell.rs +++ b/proa_marketing/src/components/landing_shell.rs @@ -385,6 +385,7 @@ pub(crate) const DOCUMENTATION_LINKS: FooterLinks = FooterLinks { ("Python SDK on PyPI", site::PYTHON_SDK_URL), ("TypeScript SDK on npm", site::TYPESCRIPT_SDK_URL), ("Official MCP Registry", site::MCP_REGISTRY_URL), + ("MCP on Glama", site::MCP_GLAMA_URL), ("Documentation index", routes::DOCS_INDEX), ("Framework documentation index", routes::FRAMEWORK_INDEX), ("RSJS documentation index", routes::RSJS_INDEX), diff --git a/proa_marketing/src/data/site.rs b/proa_marketing/src/data/site.rs index 8a2be56..09cdd30 100644 --- a/proa_marketing/src/data/site.rs +++ b/proa_marketing/src/data/site.rs @@ -40,6 +40,7 @@ pub const PYTHON_SDK_URL: &str = "https://pypi.org/project/proa-docs/"; pub const TYPESCRIPT_SDK_URL: &str = "https://www.npmjs.com/package/@proa-labs/docs"; pub const MCP_REGISTRY_URL: &str = "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.proa-web%2Fdocs"; +pub const MCP_GLAMA_URL: &str = "https://glama.ai/mcp/connectors/so.proa/proa-documentation"; pub const SOCIAL_USERNAME: &str = "drewhariri"; pub const SOCIAL_HANDLE: &str = concatcp!("@", SOCIAL_USERNAME); pub const SOCIAL_URL: &str = concatcp!("https://x.com/", SOCIAL_USERNAME); diff --git a/proa_marketing/src/docs/api.mdx b/proa_marketing/src/docs/api.mdx index 11ccb32..d2a048c 100644 --- a/proa_marketing/src/docs/api.mdx +++ b/proa_marketing/src/docs/api.mdx @@ -2,7 +2,7 @@ title: Proa documentation API and MCP description: Search and read official Proa documentation over HTTP, MCP, or browser tools without credentials. sidebar_title: API and MCP -keywords: [Proa API, MCP, WebMCP, OpenAPI, authentication, pagination, rate limits] +keywords: [Proa API, MCP, WebMCP, OpenAPI, authentication, pagination, rate limits, idempotency] --- The public Proa documentation API searches and reads the same compiled pages as this website. Use it when a coding agent needs current Rust APIs, render patterns, RSJS instructions, or deployment guidance. All operations are read-only. No account, API key, OAuth token, payment, or signup is required. @@ -23,6 +23,23 @@ curl 'https://proa.so/api/v1/search?q=streaming&limit=5' Pass the returned `next_cursor` as `cursor` with the same query to read the next page. A null cursor means there are no more results. `limit` defaults to 10 and accepts 1–50. Cursors belong to a result set on the current deployment; restart pagination if the documentation changes during retrieval. +### JSON search and retry replay + +`POST /api/v1/search` accepts the same parameters as a JSON object and returns the same typed response. The body limit is 16 KiB. Use it when your integration sends structured JSON requests. An optional `Idempotency-Key` retains a successful response for retries: + +```bash +curl 'https://proa.so/api/v1/search' \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"' \ + -d '{"q":"streaming","limit":5}' +``` + +Generate a new random UUID for each distinct request; reuse it only when retrying that request. The header value must be a quoted string containing 1–128 ASCII letters, digits, dots, colons, underscores, or hyphens. Send exactly one header. Keys are shared across this public endpoint, so predictable keys can collide with other clients. + +On the serving instance, a successful keyed response is retained for fifteen minutes from the initial request. A matching retry returns the retained JSON body and `Idempotency-Replayed: true`; the first response has `Idempotency-Replayed: false`. Concurrent retries on that instance wait for the synchronous search and then replay. Replays do not extend expiry. JSON field order and whitespace do not affect matching; changing any decoded `q`, `limit`, or `cursor` value while retaining the key returns 422 with code `idempotency_key_reused`. Omitted and null optional fields are equivalent. Validation failures do not reserve a key. + +Replay is bounded to 256 keys and 8 MiB of response bodies per instance. A full cache rejects new keyed searches with 503 and code `replay_unavailable`, preserving retained entries. Use an unkeyed search or retry later. Replays still consume the normal request budget. The cache is not shared between replicas and is cleared on restart or deployment, so a retry reaching another instance can compute a fresh response. All searches are read-only and safe to repeat with or without a key, including after expiry. This header does not apply to GET, MCP, or legacy search. + ## Read and list pages ```bash @@ -71,6 +88,8 @@ Configure a Streamable HTTP MCP connection to `https://proa.so/mcp`, with no cre The server is published in the [official MCP Registry as `io.github.proa-web/docs`](https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.proa-web%2Fdocs). Its listing points to this documentation and the same hosted endpoint; no local server installation is required. +The [Proa documentation connector on Glama](https://glama.ai/mcp/connectors/so.proa/proa-documentation) also lists `https://proa.so/mcp`, its public tools, and connection health. Use either directory to find the same service. + - `search_docs` accepts `q`, optional `limit`, and optional `cursor`. It returns the same document list as HTTP search. - `get_doc` accepts `path` and returns a complete Markdown document with its metadata. - `resources/list` lists public Markdown resources with names, descriptions, media types, and pagination. @@ -95,9 +114,9 @@ When the browser supports WebMCP, the website registers `search_docs` and `get_d The HTTP API and MCP endpoint share a budget of 600 requests per minute per running server instance. It is a shared service budget, not a personal quota. Responses include `RateLimit-Policy`, `RateLimit`, and `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`. A 429 response includes `Retry-After` in seconds; wait that long before retrying. These responses use `Cache-Control: no-store` so a cached budget is not mistaken for a current one. -Versioned REST failures use `application/problem+json` with `type`, `title`, `status`, `code`, `detail`, and `documentation`. Codes include `invalid_request` (400), `not_found` (404), `method_not_allowed` (405), and `rate_limited` (429). Correct invalid parameters; use the document index to recover from missing paths. For network failures or temporary 5xx responses, retry with exponential backoff. +Versioned REST failures use `application/problem+json` with `type`, `title`, `status`, `code`, `detail`, and `documentation`. Codes include `invalid_request` (400), `not_found` (404), `method_not_allowed` (405), and `rate_limited` (429). JSON POST search also reports `payload_too_large` (413), `unsupported_media_type` (415), `idempotency_key_reused` (422), and `replay_unavailable` (503). Correct invalid parameters; use the document index to recover from missing paths. For network failures or temporary 5xx responses, retry with exponential backoff. -All documented operations are synchronous reads and safe to repeat. There are no state-changing operations requiring an `Idempotency-Key` and no long-running operations requiring a `202 Accepted` job or polling URL. Even legacy `POST /api/search` only searches documents; it creates no record or side effect. Do not send credentials or personal data in search queries. See [authentication policy](/auth.md). +All documented operations are synchronous reads and safe to repeat. JSON POST search supports the optional response replay described above; no operation requires an `Idempotency-Key`. There are no long-running operations requiring a `202 Accepted` job or polling URL. Even legacy `POST /api/search` only searches documents; it creates no record or side effect. Do not send credentials or personal data in search queries. See [authentication policy](/auth.md). ## Versions and compatibility diff --git a/proa_marketing/src/endpoints/api.rs b/proa_marketing/src/endpoints/api.rs index 02043ee..a3af427 100644 --- a/proa_marketing/src/endpoints/api.rs +++ b/proa_marketing/src/endpoints/api.rs @@ -5,14 +5,21 @@ use crate::data::routes; +mod replay; +pub use replay::SearchReplayCache; + use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; use axum::{ - extract::{rejection::QueryRejection, Query, Request, State}, - http::{header, HeaderValue, StatusCode}, + body::Bytes, + extract::{ + rejection::{JsonRejection, QueryRejection}, + Query, Request, State, + }, + http::{header, HeaderMap, HeaderValue, StatusCode}, middleware::Next, response::{IntoResponse, Response}, Json, @@ -28,16 +35,18 @@ const DEFAULT_LIMIT: usize = 10; const MAX_LIMIT: usize = 50; pub const REQUESTS_PER_MINUTE: u32 = 600; -#[derive(Debug, Deserialize, IntoParams, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, IntoParams, JsonSchema, ToSchema)] #[serde(deny_unknown_fields)] #[into_params(parameter_in = Query)] pub struct SearchInput { /// Phrase to find in document titles, descriptions, or Markdown. #[param(min_length = 1, max_length = 256)] + #[schema(min_length = 1, max_length = 256)] #[schemars(length(min = 1, max = 256))] pub q: String, /// Maximum results per page; defaults to 10. #[param(minimum = 1, maximum = 50, default = 10, nullable = false)] + #[schema(minimum = 1, maximum = 50)] #[schemars(range(min = 1, max = MAX_LIMIT))] pub limit: Option, /// next_cursor from a preceding search with the same q. @@ -271,6 +280,63 @@ pub async fn search_handler( search(query(input)?).map(Json) } +/// Search using a JSON body, with optional response replay for retries. +/// +/// Idempotency-Key is a quoted opaque string containing 1–128 ASCII letters, +/// digits, dots, colons, underscores, or hyphens. Use a random UUID per request. +/// Successful responses are retained for fifteen minutes on the serving +/// instance. Reusing an active key with a different decoded JSON request is +/// rejected with 422. JSON field order and whitespace do not affect matching. +/// The cache is bounded and is not shared between replicas or across restarts. +/// All searches remain read-only and safe to repeat even after cache expiry. +#[utoipa::path(post, path = routes::API_SEARCH, operation_id = "searchDocsPost", + request_body = SearchInput, + params(("Idempotency-Key" = Option, Header, description = "Optional quoted request key. Fifteen-minute, per-instance response replay; not shared across replicas or restarts.", pattern = r#"^"[A-Za-z0-9._:-]{1,128}"$"#)), + responses(ApiError, + (status = 200, description = "A page of public document summaries.", body = DocumentList, + headers(("Idempotency-Replayed" = bool, description = "Present for keyed requests: true when replaying a retained JSON response."))), + (status = 413, description = "JSON body exceeds 16 KiB.", body = Problem, content_type = "application/problem+json"), + (status = 415, description = "Expected application/json.", body = Problem, content_type = "application/problem+json"), + (status = 422, description = "The key was already used for a different decoded request.", body = Problem, content_type = "application/problem+json"), + (status = 503, description = "The bounded replay cache cannot accept another request. Retry later or use an unkeyed read.", body = Problem, content_type = "application/problem+json") + ))] +pub async fn search_post_handler( + State(cache): State>, + headers: HeaderMap, + input: Result, JsonRejection>, +) -> Result { + let Json(input) = input.map_err(|error| match error.status() { + StatusCode::PAYLOAD_TOO_LARGE => ApiError { + status: StatusCode::PAYLOAD_TOO_LARGE, + code: "payload_too_large", + detail: "The JSON search body must not exceed 16 KiB.", + }, + StatusCode::UNSUPPORTED_MEDIA_TYPE => ApiError { + status: StatusCode::UNSUPPORTED_MEDIA_TYPE, + code: "unsupported_media_type", + detail: "Send the search object with Content-Type: application/json.", + }, + _ => ApiError::invalid( + "Expected a JSON search object with q and optional limit/cursor fields.", + ), + })?; + let Some(key) = replay::request_key(&headers)? else { + return search(input).map(|result| Json(result).into_response()); + }; + let fingerprint = replay::fingerprint(&input); + let result = cache.get_or_compute(key, fingerprint, Instant::now(), || { + search(input).map(|result| { + Bytes::from(serde_json::to_vec(&result).expect("serialize search response")) + }) + })?; + let mut response = ([(header::CONTENT_TYPE, "application/json")], result.body).into_response(); + response.headers_mut().insert( + "idempotency-replayed", + HeaderValue::from_static(if result.replayed { "true" } else { "false" }), + ); + Ok(response) +} + /// List public documentation with stable paths and Markdown URLs. /// /// Includes public pages excluded from search. Use next_cursor to continue @@ -304,7 +370,7 @@ pub async fn method_not_allowed() -> ApiError { status: StatusCode::METHOD_NOT_ALLOWED, code: "method_not_allowed", detail: const_format::concatcp!( - "This documentation endpoint supports GET and HEAD. See ", + "This method is not supported by this documentation endpoint. See ", routes::OPENAPI, " for supported operations." ), diff --git a/proa_marketing/src/endpoints/api/replay.rs b/proa_marketing/src/endpoints/api/replay.rs new file mode 100644 index 0000000..5415e79 --- /dev/null +++ b/proa_marketing/src/endpoints/api/replay.rs @@ -0,0 +1,317 @@ +// Copyright Proa Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded response replay for the synchronous, public JSON search operation. +//! Entries are local to one serving process; no account or mutation is involved. + +use std::{ + collections::HashMap, + sync::Mutex, + time::{Duration, Instant}, +}; + +use axum::{body::Bytes, http::HeaderMap}; +use sha2::{Digest, Sha256}; + +use super::{ApiError, SearchInput, StatusCode}; + +const RETENTION: Duration = Duration::from_secs(15 * 60); +const MAX_ENTRIES: usize = 256; +const MAX_BODY_BYTES: usize = 8 * 1024 * 1024; + +type DigestBytes = [u8; 32]; + +struct Entry { + fingerprint: DigestBytes, + body: Bytes, + expires: Instant, +} + +#[derive(Default)] +struct Store { + entries: HashMap, + body_bytes: usize, +} + +#[derive(Default)] +pub struct SearchReplayCache { + store: Mutex, +} + +#[derive(Debug)] +pub(super) struct ReplayResponse { + pub body: Bytes, + pub replayed: bool, +} + +fn unavailable() -> ApiError { + ApiError { + status: StatusCode::SERVICE_UNAVAILABLE, + code: "replay_unavailable", + detail: "Response replay is unavailable or full. Retry later or omit the key for this safe, read-only search.", + } +} + +impl SearchReplayCache { + pub(super) fn get_or_compute( + &self, + key: DigestBytes, + fingerprint: DigestBytes, + now: Instant, + build: impl FnOnce() -> Result, + ) -> Result { + let mut store = self.store.lock().map_err(|_| unavailable())?; + let Store { + entries, + body_bytes, + } = &mut *store; + entries.retain(|_, entry| { + if entry.expires > now { + true + } else { + *body_bytes -= entry.body.len(); + false + } + }); + + if let Some(entry) = entries.get(&key) { + if entry.fingerprint != fingerprint { + return Err(ApiError { + status: StatusCode::UNPROCESSABLE_ENTITY, + code: "idempotency_key_reused", + detail: "This Idempotency-Key was already used for a different request. Generate a new key when q, limit, or cursor changes.", + }); + } + return Ok(ReplayResponse { + body: entry.body.clone(), + replayed: true, + }); + } + if entries.len() >= MAX_ENTRIES { + return Err(unavailable()); + } + + // Search and serialization are synchronous and bounded. Keep the lock + // until insertion so concurrent retries compute once and then replay. + // No I/O or await occurs here. Failed searches never reserve the key. + let body = build()?; + if body.len() > MAX_BODY_BYTES - *body_bytes { + return Err(unavailable()); + } + *body_bytes += body.len(); + entries.insert( + key, + Entry { + fingerprint, + body: body.clone(), + expires: now + RETENTION, + }, + ); + Ok(ReplayResponse { + body, + replayed: false, + }) + } +} + +pub(super) fn request_key(headers: &HeaderMap) -> Result, ApiError> { + let mut values = headers.get_all("idempotency-key").iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + let invalid = || { + ApiError::invalid("Send one Idempotency-Key: a quoted string of 1–128 ASCII letters, digits, dots, colons, underscores, or hyphens.") + }; + if values.next().is_some() { + return Err(invalid()); + } + let value = value.to_str().map_err(|_| invalid())?; + let key = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .filter(|key| (1..=128).contains(&key.len())) + .filter(|key| { + key.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) + }) + .ok_or_else(invalid)?; + // Store only a digest, never the supplied key or arbitrary header data. + Ok(Some(Sha256::digest(key.as_bytes()).into())) +} + +pub(super) fn fingerprint(input: &SearchInput) -> DigestBytes { + // Typed serialization gives stable field order. JSON whitespace and object + // field order do not change the identity of the decoded request. + Sha256::digest(serde_json::to_vec(input).expect("serialize search input")).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body() -> Result { + Ok(Bytes::from_static(b"first response")) + } + + #[test] + fn retries_replay_without_extending_expiry_or_allowing_different_payloads() { + let cache = SearchReplayCache::default(); + let now = Instant::now(); + let first = cache.get_or_compute([1; 32], [2; 32], now, body).unwrap(); + assert!(!first.replayed); + let replay = cache + .get_or_compute([1; 32], [2; 32], now + RETENTION / 2, || { + panic!("a retry must not recompute") + }) + .unwrap(); + assert!(replay.replayed); + assert_eq!(replay.body, first.body); + let conflict = cache + .get_or_compute([1; 32], [3; 32], now, || panic!("mismatched key")) + .unwrap_err(); + assert_eq!(conflict.status, StatusCode::UNPROCESSABLE_ENTITY); + let expired = cache + .get_or_compute([1; 32], [3; 32], now + RETENTION, || { + Ok(Bytes::from_static(b"new response")) + }) + .unwrap(); + assert!(!expired.replayed); + assert_ne!(expired.body, first.body); + } + + #[test] + fn failed_requests_do_not_reserve_keys() { + let cache = SearchReplayCache::default(); + let now = Instant::now(); + assert!(cache + .get_or_compute([1; 32], [2; 32], now, || Err(ApiError::invalid( + "bad query" + ))) + .is_err()); + assert!( + !cache + .get_or_compute([1; 32], [3; 32], now, body) + .unwrap() + .replayed + ); + } + + #[test] + fn full_cache_preserves_active_entries_and_reclaims_expired_capacity() { + let cache = SearchReplayCache::default(); + let now = Instant::now(); + for index in 0..MAX_ENTRIES { + let key = Sha256::digest(index.to_le_bytes()).into(); + cache.get_or_compute(key, [0; 32], now, body).unwrap(); + } + assert_eq!( + cache + .get_or_compute([1; 32], [0; 32], now, body) + .unwrap_err() + .status, + StatusCode::SERVICE_UNAVAILABLE + ); + let first_key = Sha256::digest(0_usize.to_le_bytes()).into(); + assert!( + cache + .get_or_compute(first_key, [0; 32], now, || panic!( + "must retain active keys" + )) + .unwrap() + .replayed + ); + assert!( + !cache + .get_or_compute([1; 32], [0; 32], now + RETENTION, body) + .unwrap() + .replayed + ); + } + + #[test] + fn byte_budget_rejects_new_keys_without_losing_retained_responses() { + let cache = SearchReplayCache::default(); + let now = Instant::now(); + cache + .get_or_compute([1; 32], [0; 32], now, || { + Ok(Bytes::from(vec![0; MAX_BODY_BYTES])) + }) + .unwrap(); + assert_eq!( + cache + .get_or_compute([2; 32], [0; 32], now, body) + .unwrap_err() + .status, + StatusCode::SERVICE_UNAVAILABLE + ); + assert!( + cache + .get_or_compute([1; 32], [0; 32], now, body) + .unwrap() + .replayed + ); + assert!(cache + .get_or_compute([2; 32], [0; 32], now + RETENTION, body) + .is_ok()); + } + + #[test] + fn concurrent_retries_compute_one_response() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Barrier, + }; + let cache = SearchReplayCache::default(); + let started = Barrier::new(8); + let computations = AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| { + started.wait(); + let result = cache + .get_or_compute([1; 32], [2; 32], Instant::now(), || { + computations.fetch_add(1, Ordering::Relaxed); + body() + }) + .unwrap(); + assert_eq!(result.body, body().unwrap()); + }); + } + }); + assert_eq!(computations.load(Ordering::Relaxed), 1); + } + + #[test] + fn key_parser_rejects_ambiguous_or_unbounded_headers() { + let mut headers = HeaderMap::new(); + assert!(request_key(&headers).unwrap().is_none()); + for value in [ + "plain", + "\"\"", + "\"has space\"", + "\"a\", \"b\"", + "\"a\\\"b\"", + "\"a\";param=1", + ] { + headers.insert("idempotency-key", value.parse().unwrap()); + assert_eq!( + request_key(&headers).unwrap_err().status, + StatusCode::BAD_REQUEST + ); + } + headers.insert( + "idempotency-key", + format!("\"{}\"", "a".repeat(129)).parse().unwrap(), + ); + assert!(request_key(&headers).is_err()); + headers.insert( + "idempotency-key", + format!("\"{}\"", "a".repeat(128)).parse().unwrap(), + ); + assert!(request_key(&headers).unwrap().is_some()); + headers.insert("idempotency-key", "\"abc-123._:XYZ\"".parse().unwrap()); + assert!(request_key(&headers).unwrap().is_some()); + headers.append("idempotency-key", "\"second\"".parse().unwrap()); + assert!(request_key(&headers).is_err()); + } +} diff --git a/proa_marketing/src/main.rs b/proa_marketing/src/main.rs index ff645a4..678849e 100644 --- a/proa_marketing/src/main.rs +++ b/proa_marketing/src/main.rs @@ -64,6 +64,7 @@ fn listen_addr(host: &str, port: u16) -> anyhow::Result { fn app() -> Router { let budget = Arc::new(Mutex::new(endpoints::api::RequestBudget::default())); + let search_replay = Arc::new(endpoints::api::SearchReplayCache::default()); let agent_api = Router::new() .route( routes::API, @@ -77,7 +78,11 @@ fn app() -> Router { ) .route( routes::API_SEARCH, - get(endpoints::api::search_handler).fallback(endpoints::api::method_not_allowed), + get(endpoints::api::search_handler) + .post(endpoints::api::search_post_handler) + .fallback(endpoints::api::method_not_allowed) + .with_state(search_replay) + .layer(axum::extract::DefaultBodyLimit::max(16 * 1024)), ) .route( routes::API_DOCS, From eb6918d32fb98e7469dfca402d0290746bc742eb Mon Sep 17 00:00:00 2001 From: Andrew Hariri <31301117+hariria@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:20:50 -0700 Subject: [PATCH 2/6] Link verified Proa Labs LinkedIn identity --- docs/agent-readiness.md | 44 +++++++++++-------- proa_marketing/src/agent_tests.rs | 6 +++ .../src/components/landing_shell.rs | 21 +++++---- proa_marketing/src/data/site.rs | 1 + proa_marketing/src/layout/structured_data.rs | 4 +- proa_marketing/src/pages/home.rs | 4 +- 6 files changed, 50 insertions(+), 30 deletions(-) diff --git a/docs/agent-readiness.md b/docs/agent-readiness.md index 9765183..e545af2 100644 --- a/docs/agent-readiness.md +++ b/docs/agent-readiness.md @@ -1,12 +1,14 @@ # Agent-readiness rollout -Snapshot: 2026-10-28 at 18:19 UTC. After PR #56 merged and production revision -`5e4422dd59200e25118652f6ec7c458a8cee75d7` deployed, a forced -[Ora scan](https://ora.ai/score/proa.so) completed at **91/100**, up from -89/100 before deployment. Response-schema coverage and function-calling -compatibility now pass. A perfect score is not guaranteed: some checks depend -on search results, registry recognition, or whether Ora treats read-only APIs -as exempt from write/job features. +Snapshot: 2026-10-29 at 00:19 UTC. After PR #58 merged and production revision +`c5e1fa3434d6a7f59356127f150afca4f2274b6d` deployed, a forced +[Ora scan](https://ora.ai/score/proa.so) completed at **94/100**, up from +91/100 after PR #56. Public API documentation, schema breadth, response-schema +coverage, function-calling compatibility, and the official MCP Registry now +pass. Ora detects both npm and PyPI SDKs (2/3); a third ecosystem is needed +for the remaining SDK point. A perfect score is not guaranteed: some checks +depend on search samples or whether Ora treats read-only APIs as exempt from +write/job features. Ora's [check catalog](https://ora.ai/api/checks) marks the skills-install check as **emerging**, which is excluded from the canonical score. Its 200-install @@ -21,7 +23,7 @@ not a readiness-score deduction. | Partial structured response coverage | `/openapi.json` now describes the four typed JSON GET operations. The full ten-operation website/legacy contract remains at `/openapi/site.json`; all existing routes remain available. | | No SDKs or detected CLI | Published dependency-free JavaScript/TypeScript and Python clients with `proa-docs` CLIs, typed responses, pagination, bounded timeouts, and structured errors. These read docs and do not replace the Rust build CLI. | | Missing section indexes | Framework, RSJS, and ecosystem `llms.txt` indexes, linked from the homepage HTML and Markdown. | -| Limited schema breadth | Visible homepage FAQ shared with Markdown and FAQPage JSON-LD; per-document TechArticle/APIReference and BreadcrumbList metadata. | +| Limited schema breadth | Visible homepage FAQ, videos, and benchmark charts share their content with FAQPage, VideoObject, ItemList, and ImageObject metadata. Documents expose TechArticle/APIReference and BreadcrumbList metadata. | | Limited identity links | Verified Proa YouTube channel added alongside GitHub in `sameAs`. | | Three authoring skills | Added documentation API and typed Markdown skills, bringing the published index to five after deployment. | | No registry listing | Published the validated remote-server manifest as `io.github.proa-web/docs`, pointing directly to `https://proa.so/mcp`; the API guide includes its verified backlink. | @@ -39,7 +41,8 @@ and consistent HTML/Markdown discovery links. | PyPI | Published [proa-docs 0.1.1](https://pypi.org/project/proa-docs/0.1.1/) as `proa`, with wheel and source distribution. A fresh registry installation searched the live API. Both temporary release tokens were revoked and deletion confirmed by PyPI. | Complete. | | Smithery | Created the Proa Labs workspace (`proa-labs`); attempted `proa-docs` registration. The final step returned 403: "Updating servers has been removed." No listing was confirmed. | Resume when Smithery supports publication, or resolve through its support process. | | Glama | The [Proa documentation connector](https://glama.ai/mcp/connectors/so.proa/proa-documentation) is publicly listed. Verified October 29: Healthy, Streamable HTTP, endpoint `https://proa.so/mcp`, and both read-only tools. | This follow-up adds the exact listing URL to the API guide and shared homepage HTML/Markdown footer; recheck Ora after deployment. | -| Official MCP Registry | Published [io.github.proa-web/docs 1.0.0](https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.proa-web%2Fdocs) using the Proa GitHub identity. The registry reports it active and the API guide's reciprocal link is deployed. | Ora still does not recognize the listing. | +| Official MCP Registry | Published [io.github.proa-web/docs 1.0.0](https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.proa-web%2Fdocs) using the Proa GitHub identity. Ora now verifies it through the first-party backlink (1/1). | Complete. | +| LinkedIn | The user created [Proa Labs](https://www.linkedin.com/company/proa-labs/). Verified its name, Rust tagline, and website link to `https://proa.so`. | This follow-up adds it to Organization `sameAs` and the shared HTML/Markdown homepage links. | | PulseMCP | Site states that new submissions are paused. | Resume when submissions reopen; no submission was made. | | MCP.so | Submission flow requires a $39 one-time payment. | Optional paid submission needs a spending decision; no payment or submission was made. | | Google Search Console | Verified the Proa account's domain ownership using an additional Cloudflare TXT record. Google processed `https://proa.so/sitemap.xml` successfully and discovered 125 pages. Indexing requests for `/docs/api` and `/docs/agents` were accepted; the latter was already indexed. | Allow recrawling and recheck Ora's name-based search sample. | @@ -48,17 +51,16 @@ Publisher credentials stay outside the repository. Temporary PyPI release tokens are revoked after publication; no secrets belong in this document or source control. -The October 28 scan recognizes the npm SDK (1/1) and CLI (3/3). It still -detects only npm for multi-language SDKs (1/3), despite the verified PyPI -publication, and has not recognized the official MCP listing (0/1). The -deployed API guide already links both packages and the official registry. -A web search during the follow-up also surfaced `/docs/api`, despite Ora's -name-search warning. Search providers and sampled queries can differ. +The October 29 scan recognizes the npm SDK (1/1), CLI (3/3), both SDK +ecosystems (2/3), and the official MCP listing (1/1). The production homepage +and API guide link both packages and the registry. Name-based developer +resource discovery remains 1/3 despite `/docs/api` appearing in a separate +web search; search providers and sampled queries can differ. ## Follow-up fixes -PR #58 merged on October 29 at 00:00 UTC. Its normal production deployment -must finish before the canonical scan can reflect these homepage changes. +PR #58 merged on October 29 at 00:00 UTC and its normal production deployment +completed before the 00:19 scan. These homepage changes are live: - Put the API guide before subsection indexes in the shared homepage footer. The deployed scan inspected only `/docs`, `/docs/llms.txt`, and @@ -76,7 +78,7 @@ both changed checks under contract 1.37.0: public API documentation **3/3** update the canonical production score. All 161 marketing tests, Proa format and lint checks, and the render-allocation lint passed. -The next follow-up adds a functional optional `Idempotency-Key` to a typed +PR #59 adds a functional optional `Idempotency-Key` to a typed JSON `POST /api/v1/search`. A successful request is retained for fifteen minutes on the serving instance. Matching retries replay its JSON body; changed parameters with an active key return 422. The bounded cache rejects @@ -94,6 +96,12 @@ All 169 marketing tests passed, including replay, expiry, capacity, concurrent requests, header validation, body limits, and typed errors. This preview check does not update the canonical production score. +After adding the verified LinkedIn company profile, a second preview check +under contract 1.38.0 awarded entity linking **2/2** and identified both +`github.com` and `linkedin.com`. The same URL constant supplies Organization +`sameAs` and the homepage's shared HTML/Markdown Explore links. The 169 +marketing tests and formatting/performance checks still pass. + ## Deployment and rescan 1. Review and merge the follow-up PR. The current authorization permits diff --git a/proa_marketing/src/agent_tests.rs b/proa_marketing/src/agent_tests.rs index 2a8e91e..344389a 100644 --- a/proa_marketing/src/agent_tests.rs +++ b/proa_marketing/src/agent_tests.rs @@ -93,6 +93,7 @@ async fn homepage_negotiates_markdown_and_keeps_html_for_browsers() { "{first_docs:?}" ); for url in [ + site::LINKEDIN_URL, site::PYTHON_SDK_URL, site::TYPESCRIPT_SDK_URL, site::MCP_REGISTRY_URL, @@ -168,6 +169,11 @@ async fn homepage_negotiates_markdown_and_keeps_html_for_browsers() { .find(|entity| entity["@type"] == "Organization") .unwrap(); assert_eq!(organization["name"], site::ORGANIZATION); + assert!(organization["sameAs"] + .as_array() + .unwrap() + .iter() + .any(|profile| profile == site::LINKEDIN_URL)); assert_eq!( organization["contactPoint"][0]["email"], site::PRIVACY_EMAIL diff --git a/proa_marketing/src/components/landing_shell.rs b/proa_marketing/src/components/landing_shell.rs index 444a55b..f8d76f3 100644 --- a/proa_marketing/src/components/landing_shell.rs +++ b/proa_marketing/src/components/landing_shell.rs @@ -378,6 +378,17 @@ pub(crate) struct FooterLinks { links: &'static [(&'static str, &'static str)], } +pub(crate) const EXPLORE_LINKS: FooterLinks = FooterLinks { + title: "Explore", + links: &[ + ("Apps", routes::APPS), + ("Ecosystem", routes::ECOSYSTEM), + ("About", routes::ABOUT), + ("Contact", routes::CONTACT), + ("LinkedIn", site::LINKEDIN_URL), + ], +}; + pub(crate) const DOCUMENTATION_LINKS: FooterLinks = FooterLinks { title: "Documentation tools", links: &[ @@ -531,15 +542,7 @@ impl WebRenderSync for LandingFooter { */