Improve agent discovery, Markdown negotiation and API contracts
20 files+528−150/20 viewed
| 23 | 23 | mod telemetry; | |
| 24 | 24 | mod deploy_keys; | |
| 25 | 25 | mod limits; | |
| 26 | + | mod version; | |
| 26 | 27 | mod logs; | |
| 27 | 28 | mod mcp; | |
| 28 | 29 | mod notifications; | |
| ⋯ | |||
| 434 | 435 | } | |
| 435 | 436 | } | |
| 436 | 437 | ||
| 437 | − | async fn respond(mut request: Request, env: &Env) -> Result<Response> { | |
| 438 | + | async fn respond(mut request: Request, env: &Env, rate_policy: &mut Option<limits::Policy>) -> Result<Response> { | |
| 438 | 439 | let method = method_name(request.method()); | |
| 439 | 440 | if method == "OPTIONS" { | |
| 440 | 441 | return Ok(Response::empty()?.with_status(204)); | |
| ⋯ | |||
| 450 | 451 | // MCP is a host of its own hosted, and may be a path on this one | |
| 451 | 452 | // self-hosted (addresses.rs). | |
| 452 | 453 | let on_mcp = services.addresses.mcp_path(&url).is_some(); | |
| 454 | + | if !on_mcp && !version::supported(request.headers().get(version::HEADER)?.as_deref()) { | |
| 455 | + | return fail(FailureCode::Invalid, "Unsupported X-G1t-Api-Version. Use 1 or omit the header. See https://docs.g1t.sh/reference/api/#versioning-and-deprecation."); | |
| 456 | + | } | |
| 453 | 457 | ||
| 454 | 458 | // Stripe reporting to billing. Signed with the secret of the endpoint | |
| 455 | 459 | // billing registered; the body goes through exactly as received, since | |
| ⋯ | |||
| 557 | 561 | } | |
| 558 | 562 | ||
| 559 | 563 | // Per token, or per address without one (limits.rs). | |
| 560 | − | if let Some(limited) = limits::limited(&request, env, method, &path, on_mcp).await? { | |
| 564 | + | if let Some(limited) = limits::limited(&request, env, method, &path, on_mcp, rate_policy).await? { | |
| 561 | 565 | return Ok(limited); | |
| 562 | 566 | } | |
| 563 | 567 | let viewer = match authenticate(&request, &services).await? { | |
| 564 | 568 | Ok(viewer) => viewer, | |
| 565 | − | Err(refused) => return Ok(limits::wrong_token(&request, env, on_mcp).await?.unwrap_or(refused)), | |
| 569 | + | Err(refused) => return Ok(limits::wrong_token(&request, env, on_mcp, rate_policy).await?.unwrap_or(refused)), | |
| 566 | 570 | }; | |
| 567 | 571 | // A person who has not confirmed their email address: who they are, | |
| 568 | 572 | // their addresses, and confirming one, nothing else (REST or MCP). | |
| ⋯ | |||
| 884 | 888 | if let Some(signal) = otlp_signal(&request.path()) { | |
| 885 | 889 | return forward_otlp(request, &env, signal).await; | |
| 886 | 890 | } | |
| 887 | − | let mut response = respond(request, &env).await?; | |
| 891 | + | let on_mcp = addresses::Addresses::from_env(&env).mcp_path(&request.url()?).is_some(); | |
| 892 | + | let mut rate_policy = None; | |
| 893 | + | let mut response = respond(request, &env, &mut rate_policy).await?; | |
| 894 | + | if let Some(policy) = rate_policy { policy.apply(&mut response)?; } | |
| 888 | 895 | let headers = response.headers_mut(); | |
| 896 | + | if !on_mcp { headers.set(version::HEADER, version::CURRENT)?; } | |
| 889 | 897 | headers.set("access-control-allow-origin", "*")?; | |
| 890 | 898 | headers.set( | |
| 891 | 899 | "access-control-allow-headers", | |
| 892 | − | "authorization, content-type", | |
| 900 | + | "authorization, content-type, x-g1t-api-version", | |
| 893 | 901 | )?; | |
| 894 | 902 | headers.set("access-control-allow-methods", "GET, POST, PATCH, OPTIONS")?; | |
| 895 | − | headers.set("access-control-expose-headers", "www-authenticate, retry-after")?; | |
| 903 | + | headers.set("access-control-expose-headers", "www-authenticate, retry-after, ratelimit-policy, ratelimit, x-g1t-api-version, deprecation, sunset, link")?; | |
| 896 | 904 | Ok(response) | |
| 897 | 905 | } | |
| 21 | 21 | pub const ANONYMOUS: &str = "API_ANONYMOUS_LIMIT"; | |
| 22 | 22 | pub const TOKEN: &str = "API_TOKEN_LIMIT"; | |
| 23 | 23 | ||
| 24 | + | #[derive(Clone, Copy)] | |
| 25 | + | pub struct Policy { signed_in: bool, limited: bool } | |
| 26 | + | ||
| 27 | + | impl Policy { | |
| 28 | + | fn name(self) -> &'static str { if self.signed_in { "token" } else { "anonymous" } } | |
| 29 | + | fn header(self) -> String { | |
| 30 | + | format!("\"{}\";q={};w={PERIOD_SECONDS}", self.name(), if self.signed_in { 1000 } else { 60 }) | |
| 31 | + | } | |
| 32 | + | fn remaining(self) -> Option<String> { | |
| 33 | + | self.limited.then(|| format!("\"{}\";r=0;t={PERIOD_SECONDS}", self.name())) | |
| 34 | + | } | |
| 35 | + | pub fn apply(self, response: &mut Response) -> Result<()> { | |
| 36 | + | response.headers_mut().set("ratelimit-policy", &self.header())?; | |
| 37 | + | // The binding exposes a verdict, not remaining quota or a reset clock. | |
| 38 | + | // Only advertise remaining/reset information when actually limited. | |
| 39 | + | if let Some(remaining) = self.remaining() { | |
| 40 | + | response.headers_mut().set("ratelimit", &remaining)?; | |
| 41 | + | } | |
| 42 | + | Ok(()) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn policy(verdict: limits::Verdict, signed_in: bool) -> Option<Policy> { | |
| 47 | + | match verdict { | |
| 48 | + | limits::Verdict::Unavailable => None, | |
| 49 | + | _ => Some(Policy { signed_in, limited: verdict.limited() }), | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 24 | 53 | /// Paths a sandbox or runner reports to with its own token in the body: | |
| 25 | 54 | /// many sandboxes share an address, and none of them is a person. | |
| 26 | 55 | const REPORTS: &[&str] = &[ | |
| ⋯ | |||
| 81 | 110 | } | |
| 82 | 111 | ||
| 83 | 112 | /// The 429 for a request past its limit, or `None` to go on. | |
| 84 | − | pub async fn limited(request: &Request, env: &Env, method: &str, path: &str, on_mcp: bool) -> Result<Option<Response>> { | |
| 113 | + | pub async fn limited(request: &Request, env: &Env, method: &str, path: &str, on_mcp: bool, advertised: &mut Option<Policy>) -> Result<Option<Response>> { | |
| 85 | 114 | let header = request.headers().get("authorization")?.unwrap_or_default(); | |
| 86 | 115 | let token = bearer(&header); | |
| 87 | 116 | if !counts(method, path, token.is_some()) { | |
| ⋯ | |||
| 89 | 118 | } | |
| 90 | 119 | let address = request.headers().get("cf-connecting-ip")?; | |
| 91 | 120 | let (binding, key) = key(token, address.as_deref(), on_mcp); | |
| 92 | − | if limits::check(env, binding, key).await.limited() { | |
| 121 | + | let verdict = limits::check(env, binding, key).await; | |
| 122 | + | *advertised = policy(verdict, token.is_some()); | |
| 123 | + | if verdict.limited() { | |
| 93 | 124 | return too_many(token.is_some()).map(Some); | |
| 94 | 125 | } | |
| 95 | 126 | Ok(None) | |
| 96 | 127 | } | |
| 97 | 128 | ||
| 98 | 129 | /// A request whose token was wrong also counts against its address. | |
| 99 | − | pub async fn wrong_token(request: &Request, env: &Env, on_mcp: bool) -> Result<Option<Response>> { | |
| 130 | + | pub async fn wrong_token(request: &Request, env: &Env, on_mcp: bool, advertised: &mut Option<Policy>) -> Result<Option<Response>> { | |
| 100 | 131 | let address = request.headers().get("cf-connecting-ip")?; | |
| 101 | 132 | let (binding, key) = key(None, address.as_deref(), on_mcp); | |
| 102 | − | if limits::check(env, binding, key).await.limited() { | |
| 133 | + | let verdict = limits::check(env, binding, key).await; | |
| 134 | + | *advertised = policy(verdict, false); | |
| 135 | + | if verdict.limited() { | |
| 103 | 136 | return too_many(false).map(Some); | |
| 104 | 137 | } | |
| 105 | 138 | Ok(None) | |
| ⋯ | |||
| 110 | 143 | use super::*; | |
| 111 | 144 | ||
| 112 | 145 | #[test] | |
| 146 | + | fn advertised_limits_match_bindings_without_inventing_remaining_counts() { | |
| 147 | + | let config: String = include_str!("../wrangler.jsonc").chars().filter(|c| !c.is_whitespace()).collect(); | |
| 148 | + | for (signed_in, binding, quota) in [(false, ANONYMOUS, 60), (true, TOKEN, 1000)] { | |
| 149 | + | let configured = config.split(&format!("\"name\":\"{binding}\"")).nth(1).unwrap().split('}').next().unwrap(); | |
| 150 | + | assert!(configured.contains(&format!("\"limit\":{quota},\"period\":{PERIOD_SECONDS}"))); | |
| 151 | + | let allowed = policy(limits::Verdict::Allowed, signed_in).unwrap(); | |
| 152 | + | assert!(allowed.header().ends_with(&format!(";q={quota};w=60"))); | |
| 153 | + | assert_eq!(allowed.remaining(), None); | |
| 154 | + | assert!(policy(limits::Verdict::Limited, signed_in).unwrap().remaining().unwrap().ends_with(";r=0;t=60")); | |
| 155 | + | } | |
| 156 | + | assert!(policy(limits::Verdict::Unavailable, false).is_none()); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 113 | 160 | fn tokens_are_counted_by_their_hash_and_others_by_address() { | |
| 114 | 161 | let (binding, tok) = key(Some("g1t_secret"), Some("203.0.113.9"), false); | |
| 115 | 162 | assert_eq!(binding, TOKEN); | |
| 1116 | 1116 | // Public data can be read without a token; everything else needs one. | |
| 1117 | 1117 | let scope: Vec<&str> = scope_for(op.name()).map(|scope| scope.as_str()).into_iter().collect(); | |
| 1118 | 1118 | let security = if op.needs_user() { | |
| 1119 | − | json!([{ "token": scope }]) | |
| 1119 | + | json!([{ "token": [] }, { "oauth": scope }]) | |
| 1120 | 1120 | } else { | |
| 1121 | − | json!([{ "token": scope }, {}]) | |
| 1121 | + | json!([{ "token": [] }, { "oauth": scope }, {}]) | |
| 1122 | 1122 | }; | |
| 1123 | 1123 | let (tool, action) = crate::tools::TOOLS | |
| 1124 | 1124 | .iter() | |
| ⋯ | |||
| 1277 | 1277 | entry[route.method.to_lowercase()] = operation(route); | |
| 1278 | 1278 | } | |
| 1279 | 1279 | attach_examples(&mut paths); | |
| 1280 | + | for methods in paths.values_mut() { | |
| 1281 | + | for operation in methods.as_object_mut().unwrap().values_mut() { | |
| 1282 | + | let parameters = operation.as_object_mut().unwrap().entry("parameters").or_insert(json!([])); | |
| 1283 | + | parameters.as_array_mut().unwrap().push(json!({ "$ref": "#/components/parameters/ApiVersion" })); | |
| 1284 | + | let responses = operation["responses"].as_object_mut().unwrap(); | |
| 1285 | + | responses.entry("422").or_insert_with(|| error_response("Invalid input or unsupported X-G1t-Api-Version.")); | |
| 1286 | + | for response in responses.values_mut() { | |
| 1287 | + | response["headers"] = json!({ | |
| 1288 | + | "X-G1t-Api-Version": { "$ref": "#/components/headers/ApiVersion" }, | |
| 1289 | + | "RateLimit-Policy": { "$ref": "#/components/headers/RateLimitPolicy" } | |
| 1290 | + | }); | |
| 1291 | + | } | |
| 1292 | + | responses.insert("429".into(), json!({ "$ref": "#/components/responses/RateLimited" })); | |
| 1293 | + | } | |
| 1294 | + | } | |
| 1295 | + | let scopes: Map<String, Value> = g1t_contracts::scopes::offered_scopes().iter() | |
| 1296 | + | .map(|scope| (scope.as_str().to_owned(), json!(scope.describe()))).collect(); | |
| 1280 | 1297 | let tags: Vec<Value> = SECTIONS | |
| 1281 | 1298 | .iter() | |
| 1282 | 1299 | .map(|(name, description, ops)| { | |
| ⋯ | |||
| 1288 | 1305 | }) | |
| 1289 | 1306 | }) | |
| 1290 | 1307 | .collect(); | |
| 1291 | − | let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid", "too_large", "unsupported_type"]; | |
| 1308 | + | let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid", "too_large", "unsupported_type", "rate_limited"]; | |
| 1292 | 1309 | json!({ | |
| 1293 | 1310 | "openapi": "3.1.0", | |
| 1294 | 1311 | "info": { | |
| ⋯ | |||
| 1302 | 1319 | "tags": tags, | |
| 1303 | 1320 | "paths": paths, | |
| 1304 | 1321 | "components": { | |
| 1322 | + | "parameters": { | |
| 1323 | + | "ApiVersion": { | |
| 1324 | + | "name": "X-G1t-Api-Version", "in": "header", "required": false, | |
| 1325 | + | "description": "REST version; defaults to 1. URLs remain unprefixed. See https://docs.g1t.sh/reference/api/#versioning-and-deprecation.", | |
| 1326 | + | "schema": { "type": "string", "enum": [crate::version::CURRENT], "default": crate::version::CURRENT } | |
| 1327 | + | } | |
| 1328 | + | }, | |
| 1329 | + | "headers": { | |
| 1330 | + | "ApiVersion": { "description": "Selected REST version.", "schema": { "type": "string", "example": "1" } }, | |
| 1331 | + | "RateLimitPolicy": { "description": "Applied quota when the limiter is available; the provider does not expose remaining quota.", "schema": { "type": "string", "example": "\"anonymous\";q=60;w=60" } } | |
| 1332 | + | }, | |
| 1333 | + | "responses": { | |
| 1334 | + | "RateLimited": { | |
| 1335 | + | "description": "Rate limited. Wait Retry-After seconds before trying again.", | |
| 1336 | + | "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }, | |
| 1337 | + | "headers": { | |
| 1338 | + | "X-G1t-Api-Version": { "$ref": "#/components/headers/ApiVersion" }, | |
| 1339 | + | "RateLimit-Policy": { "$ref": "#/components/headers/RateLimitPolicy" }, | |
| 1340 | + | "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer", "example": 60 } }, | |
| 1341 | + | "RateLimit": { "description": "Exhausted policy and retry window.", "schema": { "type": "string", "example": "\"anonymous\";r=0;t=60" } } | |
| 1342 | + | } | |
| 1343 | + | } | |
| 1344 | + | }, | |
| 1305 | 1345 | "securitySchemes": { | |
| 1306 | 1346 | "token": { | |
| 1307 | 1347 | "type": "http", | |
| 1308 | 1348 | "scheme": "bearer", | |
| 1309 | 1349 | "description": "An access token, `g1t_…`. Public data needs none. Each operation names the scope a token needs for it; see https://docs.g1t.sh/guides/authentication/#scopes.", | |
| 1310 | 1350 | }, | |
| 1351 | + | "oauth": { | |
| 1352 | + | "type": "oauth2", | |
| 1353 | + | "description": "Authorization code with PKCE (S256). Request only the scopes needed for the task; workspace and repository roles still apply.", | |
| 1354 | + | "flows": { "authorizationCode": { | |
| 1355 | + | "authorizationUrl": "https://g1t.sh/oauth/authorize", | |
| 1356 | + | "tokenUrl": "https://api.g1t.sh/oauth/token", | |
| 1357 | + | "scopes": scopes | |
| 1358 | + | } } | |
| 1359 | + | }, | |
| 1311 | 1360 | }, | |
| 1312 | 1361 | "schemas": { | |
| 1313 | 1362 | "Error": { | |
| ⋯ | |||
| 1338 | 1387 | use super::*; | |
| 1339 | 1388 | ||
| 1340 | 1389 | #[test] | |
| 1390 | + | fn oauth_scopes_versions_and_rate_limits_are_machine_readable() { | |
| 1391 | + | let doc = document(); | |
| 1392 | + | let offered = &doc["components"]["securitySchemes"]["oauth"]["flows"]["authorizationCode"]["scopes"]; | |
| 1393 | + | assert_eq!(offered.as_object().unwrap().len(), g1t_contracts::scopes::offered_scopes().len()); | |
| 1394 | + | for scope in g1t_contracts::scopes::offered_scopes() { assert_eq!(offered[scope.as_str()], scope.describe()); } | |
| 1395 | + | assert_eq!(doc["servers"][0]["url"], "https://api.g1t.sh"); | |
| 1396 | + | for methods in doc["paths"].as_object().unwrap().values() { | |
| 1397 | + | for operation in methods.as_object().unwrap().values() { | |
| 1398 | + | assert!(operation["parameters"].as_array().unwrap().iter().any(|p| p["$ref"] == "#/components/parameters/ApiVersion")); | |
| 1399 | + | assert_eq!(operation["responses"]["429"]["$ref"], "#/components/responses/RateLimited"); | |
| 1400 | + | for requirement in operation["security"].as_array().unwrap() { | |
| 1401 | + | if let Some(scopes) = requirement.get("token") { assert_eq!(scopes, &json!([]), "HTTP bearer schemes cannot declare OAuth scopes"); } | |
| 1402 | + | if let Some(scopes) = requirement.get("oauth") { | |
| 1403 | + | for scope in scopes.as_array().unwrap() { assert!(offered.get(scope.as_str().unwrap()).is_some(), "undeclared OAuth scope {scope}"); } | |
| 1404 | + | } | |
| 1405 | + | } | |
| 1406 | + | } | |
| 1407 | + | } | |
| 1408 | + | } | |
| 1409 | + | ||
| 1410 | + | #[test] | |
| 1341 | 1411 | fn every_route_is_documented_once() { | |
| 1342 | 1412 | let document = document(); | |
| 1343 | 1413 | let mut ids = Vec::new(); | |
| 1 | + | //! REST versions use a header, leaving GitHub-compatible URLs unprefixed. | |
| 2 | + | pub const HEADER: &str = "x-g1t-api-version"; | |
| 3 | + | pub const CURRENT: &str = "1"; | |
| 4 | + | ||
| 5 | + | pub fn supported(value: Option<&str>) -> bool { | |
| 6 | + | value.is_none_or(|value| value.trim() == CURRENT) | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | #[cfg(test)] | |
| 10 | + | mod tests { | |
| 11 | + | use super::*; | |
| 12 | + | #[test] | |
| 13 | + | fn omitted_versions_keep_working_and_unknown_versions_are_not_silently_accepted() { | |
| 14 | + | assert!(supported(None)); | |
| 15 | + | assert!(supported(Some("1"))); | |
| 16 | + | assert!(supported(Some(" 1 "))); | |
| 17 | + | for value in ["", "2", "latest", "1, 2"] { assert!(!supported(Some(value))); } | |
| 18 | + | } | |
| 19 | + | } |
| 37 | 37 | ||
| 38 | 38 | The response has more entries than shown here. | |
| 39 | 39 | ||
| 40 | + | ## Versioning and deprecation | |
| 41 | + | ||
| 42 | + | g1t uses unprefixed URLs, such as `https://api.g1t.sh/repos`, like GitHub. | |
| 43 | + | The optional `X-G1t-Api-Version: 1` request header selects the current REST | |
| 44 | + | contract. Omitting it selects version 1 too. REST responses include | |
| 45 | + | `X-G1t-Api-Version: 1`; unsupported values are rejected with `422` before | |
| 46 | + | an operation runs. `/v1/` remains a compatibility alias for existing clients, | |
| 47 | + | not the preferred API base URL. MCP negotiates its own protocol version. | |
| 48 | + | ||
| 49 | + | New optional fields and endpoints may be added within a version. Clients | |
| 50 | + | should ignore fields they do not recognize. Breaking contract changes need | |
| 51 | + | a new version value and a migration guide. Before retiring a version or | |
| 52 | + | endpoint, g1t will announce its replacement and retirement date in these | |
| 53 | + | docs and on affected responses: `Deprecation` uses an RFC 9745 Structured | |
| 54 | + | Field date, `Sunset` uses an RFC 8594 HTTP date, and a `Link` with | |
| 55 | + | `rel="deprecation"` points to the migration guide. No version is currently | |
| 56 | + | deprecated and no retirement date is set; those headers are absent until | |
| 57 | + | there is an announced deprecation. A fixed minimum notice period has not | |
| 58 | + | been established. | |
| 59 | + | ||
| 40 | 60 | ## Authentication | |
| 41 | 61 | ||
| 42 | 62 | A token is needed to change anything, and to see what is private. Send an |
| 9 | 9 | normally reaches. Each counts requests over 60 seconds, approximately: a | |
| 10 | 10 | client can sometimes get a few more through before it is limited. | |
| 11 | 11 | ||
| 12 | + | ## Response headers | |
| 13 | + | ||
| 14 | + | REST and MCP responses that pass through an available rate limiter include | |
| 15 | + | `RateLimit-Policy`. Its Structured Fields syntax follows the IETF HTTPAPI | |
| 16 | + | [RateLimit draft](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-10.html), | |
| 17 | + | which is not yet a published RFC: | |
| 18 | + | ||
| 19 | + | ```http | |
| 20 | + | RateLimit-Policy: "anonymous";q=60;w=60 | |
| 21 | + | ``` | |
| 22 | + | ||
| 23 | + | Authenticated requests use `"token";q=1000;w=60`. Quotas are approximate | |
| 24 | + | per-location limits, not a global accounting balance. Cloudflare's binding | |
| 25 | + | does not expose a remaining count or reset clock, so successful responses | |
| 26 | + | do not invent those values. A limited response is HTTP 429 and includes: | |
| 27 | + | ||
| 28 | + | ```http | |
| 29 | + | RateLimit-Policy: "anonymous";q=60;w=60 | |
| 30 | + | RateLimit: "anonymous";r=0;t=60 | |
| 31 | + | Retry-After: 60 | |
| 32 | + | ``` | |
| 33 | + | ||
| 34 | + | Wait at least `Retry-After` seconds, then retry with jitter. These headers | |
| 35 | + | are exposed to browser API clients through CORS. Exempt service callbacks | |
| 36 | + | and self-hosted installations without a working limiter omit the policy. | |
| 37 | + | ||
| 12 | 38 | ## Limits | |
| 13 | 39 | ||
| 14 | 40 | | Where | Counted by | Requests a minute | |
Binary or large file; its contents are not shown.
| 7 | 7 | import { NonceContext } from "./lib/nonce"; | |
| 8 | 8 | import { makeNonce, pagePolicy } from "./lib/page-headers"; | |
| 9 | 9 | import { recordHandler } from "./lib/perf.server"; | |
| 10 | + | import { agentPage } from "./lib/agent-pages"; | |
| 10 | 11 | ||
| 11 | 12 | // React Router's own server entry, plus the timing of every loader and | |
| 12 | 13 | // action for the Server-Timing header (lib/perf.server.ts). | |
| ⋯ | |||
| 39 | 40 | routerContext: EntryContext, | |
| 40 | 41 | _loadContext: RouterContextProvider, | |
| 41 | 42 | ) { | |
| 43 | + | const agent = agentPage(request, responseStatusCode, responseHeaders); | |
| 44 | + | if (agent) return agent; | |
| 42 | 45 | // https://httpwg.org/specs/rfc9110.html#HEAD | |
| 43 | 46 | if (request.method.toUpperCase() === "HEAD") { | |
| 47 | + | responseHeaders.set("Content-Type", "text/html"); | |
| 44 | 48 | return new Response(null, { | |
| 45 | 49 | status: responseStatusCode, | |
| 46 | 50 | headers: responseHeaders, | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | import { agentPage, developerResource, pageRepresentation } from "./agent-pages.ts"; | |
| 4 | + | ||
| 5 | + | test("developer discovery aliases point at the installation's machine endpoints", () => { | |
| 6 | + | const addresses = { api: "https://api.example", mcp: "https://mcp.example" }; | |
| 7 | + | assert.equal(developerResource("/openapi.json", addresses), "https://api.example/openapi.json"); | |
| 8 | + | assert.equal(developerResource("/mcp", addresses), "https://mcp.example"); | |
| 9 | + | assert.equal(developerResource("/developers", addresses), "https://docs.g1t.sh/reference/api/"); | |
| 10 | + | assert.equal(developerResource("/private", addresses), null); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("negotiation honors explicit types, quality, specificity, exclusions and browser defaults", () => { | |
| 14 | + | for (const accept of [null, "*/*", "text/html", "text/html,application/xhtml+xml,*/*;q=0.8", "text/markdown;q=0, */*;q=1", "text/html;q=1,text/markdown;q=0.5"]) | |
| 15 | + | assert.equal(pageRepresentation(accept), "html", String(accept)); | |
| 16 | + | for (const accept of ["text/markdown", "TEXT/MARKDOWN; charset=utf-8", "text/html;q=0.5,text/markdown;q=1", "text/markdown,*/*", "text/html;q=0,text/*"]) | |
| 17 | + | assert.equal(pageRepresentation(accept), "markdown", accept); | |
| 18 | + | for (const accept of ["application/json", "text/html;q=0,text/markdown;q=0", "text/markdown;q=bad", "text/markdown;q=1.1"]) | |
| 19 | + | assert.equal(pageRepresentation(accept), "none", accept); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | test("homepage and genuine 404 documents have Markdown with the right status, discovery links and Vary", async () => { | |
| 23 | + | for (const [path, status] of [["/", 200], ["/__missing", 404]] as const) { | |
| 24 | + | const headers = new Headers({ vary: "Accept-Encoding", "content-type": "text/html" }); | |
| 25 | + | const answer = agentPage(new Request(`https://g1t.sh${path}`, { headers: { accept: "text/markdown" } }), status, headers)!; | |
| 26 | + | assert.equal(answer.status, status); | |
| 27 | + | assert.match(answer.headers.get("content-type")!, /^text\/markdown/); | |
| 28 | + | assert.equal(answer.headers.get("vary"), "Accept-Encoding, Accept"); | |
| 29 | + | const body = await answer.text(); | |
| 30 | + | assert.ok(body.length > 20); | |
| 31 | + | assert.match(body, /\[.*\]\(https:\/\/g1t.sh\/llms.txt\)/); | |
| 32 | + | assert.doesNotMatch(body, /<!DOCTYPE|<html/i); | |
| 33 | + | const htmlHeaders = new Headers(); | |
| 34 | + | assert.equal(agentPage(new Request(`https://g1t.sh${path}`, { headers: { accept: "text/html" } }), status, htmlHeaders), null); | |
| 35 | + | assert.equal(htmlHeaders.get("vary"), "Accept"); | |
| 36 | + | } | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | test("HEAD, unsupported types, private refusals and writes preserve protocol boundaries", async () => { | |
| 40 | + | const head = agentPage(new Request("https://g1t.sh/", { method: "HEAD", headers: { accept: "text/markdown" } }), 200, new Headers())!; | |
| 41 | + | assert.equal(head.headers.get("content-type"), "text/markdown; charset=utf-8"); | |
| 42 | + | assert.equal(await head.text(), ""); | |
| 43 | + | const json = agentPage(new Request("https://g1t.sh/", { headers: { accept: "application/json" } }), 200, new Headers())!; | |
| 44 | + | assert.equal(json.status, 406); | |
| 45 | + | for (const status of [301, 302, 401, 403, 500]) assert.equal(agentPage(new Request("https://g1t.sh/"), status, new Headers()), null); | |
| 46 | + | assert.equal(agentPage(new Request("https://g1t.sh/", { method: "POST" }), 404, new Headers()), null); | |
| 47 | + | assert.equal(agentPage(new Request("https://g1t.sh/private", { headers: { accept: "text/markdown" } }), 200, new Headers()), null); | |
| 48 | + | }); |
| 1 | + | /** Explicit Markdown negotiation; ordinary browsers and wildcard clients keep HTML. */ | |
| 2 | + | export function pageRepresentation(accept: string | null): "html" | "markdown" | "none" { | |
| 3 | + | if (!accept?.trim()) return "html"; | |
| 4 | + | const ranges = accept.toLowerCase().split(",").map((part) => { | |
| 5 | + | const [type, ...parameters] = part.trim().split(";").map((value) => value.trim()); | |
| 6 | + | const quality = parameters.find((value) => value.startsWith("q="))?.slice(2) ?? "1"; | |
| 7 | + | const q = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(quality) ? Number(quality) : 0; | |
| 8 | + | return { type, q }; | |
| 9 | + | }); | |
| 10 | + | function weight(type: string) { | |
| 11 | + | for (const [specificity, candidate] of [[2, type], [1, "text/*"], [0, "*/*"]] as const) { | |
| 12 | + | const matches = ranges.filter((range) => range.type === candidate); | |
| 13 | + | if (matches.length) return { q: Math.max(...matches.map((range) => range.q)), specificity }; | |
| 14 | + | } | |
| 15 | + | return { q: 0, specificity: -1 }; | |
| 16 | + | } | |
| 17 | + | const html = weight("text/html"); | |
| 18 | + | const markdown = weight("text/markdown"); | |
| 19 | + | if (html.q === 0 && markdown.q === 0) return "none"; | |
| 20 | + | return markdown.q > html.q || (markdown.q === html.q && markdown.specificity > html.specificity) ? "markdown" : "html"; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | export function varyAccept(headers: Headers) { | |
| 24 | + | const values = (headers.get("vary") ?? "").split(",").map((value) => value.trim()).filter(Boolean); | |
| 25 | + | if (!values.some((value) => value === "*" || value.toLowerCase() === "accept")) values.push("Accept"); | |
| 26 | + | headers.set("vary", values.join(", ")); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | export function developerResource(path: string, addresses: { api: string; mcp: string }): string | null { | |
| 30 | + | const resources: Record<string, string> = { | |
| 31 | + | "/developers": "https://docs.g1t.sh/reference/api/", | |
| 32 | + | "/openapi.json": `${addresses.api}/openapi.json`, | |
| 33 | + | "/mcp": addresses.mcp, | |
| 34 | + | }; | |
| 35 | + | return resources[path] ?? null; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** Called only for document responses, after routing and authorization decide the status. */ | |
| 39 | + | export function agentPage(request: Request, status: number, headers: Headers): Response | null { | |
| 40 | + | if (!["GET", "HEAD"].includes(request.method)) return null; | |
| 41 | + | const url = new URL(request.url); | |
| 42 | + | if (status !== 404 && !(status === 200 && url.pathname === "/")) return null; | |
| 43 | + | varyAccept(headers); | |
| 44 | + | const representation = pageRepresentation(request.headers.get("accept")); | |
| 45 | + | if (representation === "html") return null; | |
| 46 | + | headers.set("content-type", representation === "markdown" ? "text/markdown; charset=utf-8" : "text/plain; charset=utf-8"); | |
| 47 | + | headers.delete("content-length"); | |
| 48 | + | const body = representation === "none" ? "This page is available as text/html or text/markdown.\n" : status === 404 | |
| 49 | + | ? `# Page not found\n\nThe requested page does not exist or is not available to you.\n\nSee [g1t agent instructions](${url.origin}/llms.txt) or the [sitemap](${url.origin}/sitemap.xml) to find a public resource.\n` | |
| 50 | + | : `# g1t\n\ng1t is an open-source workspace for repositories, issues, pull requests, reviews, required checks, a merge queue, deployments, team chat and Artifacts documents. Bring the coding agents you already use through MCP, REST or the CLI.\n\n## When to use g1t\n\nUse g1t to inspect a project's code and issues, prepare and review pull requests, follow checks and deployment status, and coordinate agent work in a shared repository. Use the API or MCP for structured reads and changes; use git for cloning and pushing code.\n\n## Developer resources\n\n- [Agent instructions](${url.origin}/llms.txt)\n- [g1t documentation](https://docs.g1t.sh/)\n- [REST API and versioning](https://docs.g1t.sh/reference/api/)\n- [OpenAPI](https://api.g1t.sh/openapi.json)\n- [Authentication and scopes](https://docs.g1t.sh/guides/authentication/)\n- [MCP server](https://mcp.g1t.sh)\n- [CLI installation](https://docs.g1t.sh/guides/cli/)\n- [Pricing](${url.origin}/pricing)\n\nPublic repositories can be read without credentials. Writes and private data require authorization; request only the scopes your task needs. Never ask a user to paste their password.\n`; | |
| 51 | + | return new Response(request.method === "HEAD" ? null : body, { status: representation === "none" ? 406 : status, headers }); | |
| 52 | + | } |
| 9 | 9 | export const COMPANY = { | |
| 10 | 10 | name: "Flagon, Inc.", | |
| 11 | 11 | url: "https://www.flagon.io", | |
| 12 | + | email: "hey@flagon.io", | |
| 12 | 13 | /** Who founded it, for search engines' structured data only (structured-data.ts). */ | |
| 13 | 14 | founder: "Chase Pierce", | |
| 14 | 15 | }; |
| 7 | 7 | ||
| 8 | 8 | type Tag = Record<string, string>; | |
| 9 | 9 | ||
| 10 | + | test("the homepage declares its canonical URL using the installation's origin", () => { | |
| 11 | + | for (const site of ["https://g1t.sh", "https://forge.example"]) { | |
| 12 | + | const descriptors = page({ location: { pathname: "/" }, matches: [{ id: "root", loaderData: { addresses: { site, og: null } } }] }, { title: "g1t" }); | |
| 13 | + | assert.deepEqual(descriptors.find((tag) => "rel" in tag && tag.rel === "canonical"), { tagName: "link", rel: "canonical", href: `${site}/` }); | |
| 14 | + | } | |
| 15 | + | }); | |
| 16 | + | ||
| 10 | 17 | function tags(list: unknown[]): Map<string, string> { | |
| 11 | 18 | const found = new Map<string, string>(); | |
| 12 | 19 | for (const tag of list as Tag[]) { |
| 130 | 130 | const image = og ? cardUrl(pathname, version, og) : null; | |
| 131 | 131 | return [ | |
| 132 | 132 | { title: meta.title }, | |
| 133 | + | ...(pathname === "/" ? [{ tagName: "link" as const, rel: "canonical", href: `${site}/` }] : []), | |
| 133 | 134 | { name: "description", content: description }, | |
| 134 | 135 | { property: "og:site_name", content: "g1t" }, | |
| 135 | 136 | { property: "og:type", content: meta.type ?? "website" }, |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | import { organization } from "./structured-data.ts"; | |
| 4 | + | ||
| 5 | + | test("organization contact is the supplied public email, without an invented postal address", () => { | |
| 6 | + | const descriptor = organization() as { "script:ld+json": Record<string, unknown> }; | |
| 7 | + | assert.deepEqual(descriptor["script:ld+json"].contactPoint, { "@type": "ContactPoint", email: "hey@flagon.io", contactType: "customer support" }); | |
| 8 | + | assert.equal(descriptor["script:ld+json"].address, undefined); | |
| 9 | + | }); |
| 5 | 5 | */ | |
| 6 | 6 | import type { MetaDescriptor } from "react-router"; | |
| 7 | 7 | ||
| 8 | − | import { COMPANY, SOCIAL } from "./legal"; | |
| 9 | − | import { DESCRIPTION, SITE } from "./meta"; | |
| 8 | + | import { COMPANY, SOCIAL } from "./legal.ts"; | |
| 9 | + | import { DESCRIPTION, SITE } from "./meta.ts"; | |
| 10 | 10 | ||
| 11 | 11 | export function organization(): MetaDescriptor { | |
| 12 | 12 | return { | |
| ⋯ | |||
| 15 | 15 | "@type": "Organization", | |
| 16 | 16 | name: COMPANY.name, | |
| 17 | 17 | url: COMPANY.url, | |
| 18 | + | contactPoint: { "@type": "ContactPoint", email: COMPANY.email, contactType: "customer support" }, | |
| 18 | 19 | founder: { "@type": "Person", name: COMPANY.founder }, | |
| 19 | 20 | brand: { "@type": "Brand", name: "g1t", url: SITE, sameAs: SOCIAL.map((account) => account.url) }, | |
| 20 | 21 | }, | |
| 19 | 19 | and working. You never ask for, see, or send the person's password. Accounts | |
| 20 | 20 | are created and approved only in their browser. | |
| 21 | 21 | ||
| 22 | + | ## When to use g1t | |
| 23 | + | ||
| 24 | + | Use g1t when a task involves a g1t repository: inspect code and issues, | |
| 25 | + | prepare or review a pull request, read failed-check logs, follow a deployment, | |
| 26 | + | edit an Artifacts document, or coordinate work with other agents. Use ordinary | |
| 27 | + | git for clone and push, MCP for interactive tool calls, REST for integrations, | |
| 28 | + | and the g1t CLI for shell scripts with JSON output. Read public data without | |
| 29 | + | credentials first; request only the named scopes needed for private data or writes. | |
| 30 | + | ||
| 31 | + | Start with [g1t API discovery](https://api.g1t.sh/), | |
| 32 | + | [g1t developer resources](https://g1t.sh/developers), | |
| 33 | + | [OpenAPI](https://api.g1t.sh/openapi.json), | |
| 34 | + | [authentication and scopes](https://docs.g1t.sh/guides/authentication/), | |
| 35 | + | [MCP OAuth metadata](https://mcp.g1t.sh/.well-known/oauth-protected-resource), | |
| 36 | + | or [CLI installation](https://docs.g1t.sh/guides/cli/). | |
| 37 | + | REST URLs have no version prefix; clients can select version 1 with | |
| 38 | + | `X-G1t-Api-Version: 1`. See the | |
| 39 | + | [versioning and deprecation policy](https://docs.g1t.sh/reference/api/#versioning-and-deprecation) | |
| 40 | + | and [rate-limit headers](https://docs.g1t.sh/reference/rate-limits/). | |
| 41 | + | ||
| 42 | + | The forge has a [free plan](https://g1t.sh/pricing), and people can create | |
| 43 | + | scoped [personal access tokens](https://g1t.sh/settings/tokens) themselves. | |
| 44 | + | For isolated testing, [self-host g1t](https://docs.g1t.sh/guides/self-hosting/) | |
| 45 | + | or use a disposable repository in a workspace you control. A disposable | |
| 46 | + | hosted repository is real production data, not a separate test environment. | |
| 47 | + | Do not assume that billing or external integrations are sandboxed. | |
| 48 | + | ||
| 22 | 49 | ## Set someone up | |
| 23 | 50 | ||
| 24 | 51 | 1. **Start a sign-in.** |
| 19 | 19 | await env.DOWNLOADS.put('web-assets/assets/app-old12345.css', ${JSON.stringify(css)}, {httpMetadata:{contentType:'text/css'}}); | |
| 20 | 20 | const cache = new URL('/pricing.data', request.url); | |
| 21 | 21 | cache.searchParams.set('_g1t_build', ${JSON.stringify(build)}); | |
| 22 | + | cache.searchParams.set('_g1t_representation', 'html'); | |
| 22 | 23 | await caches.default.put(new Request(cache), new Response('wrong-build-cache', {headers:{'cache-control':'public, max-age=300','x-g1t-kept-at':String(Date.now())}})); | |
| 23 | 24 | return new Response('seeded'); | |
| 24 | 25 | } | |
| ⋯ | |||
| 31 | 32 | compatibilityDate: config.compatibility_date, | |
| 32 | 33 | compatibilityFlags: config.compatibility_flags, | |
| 33 | 34 | r2Buckets: ["DOWNLOADS"], | |
| 35 | + | serviceBindings: { | |
| 36 | + | IDENTITY: async () => Response.json(null), | |
| 37 | + | PROJECTS: async () => Response.json({ ok: true, value: [] }), | |
| 38 | + | }, | |
| 34 | 39 | })); | |
| 35 | 40 | try { | |
| 36 | 41 | const origin = await runtime.ready; | |
| 37 | 42 | const request = (path, init) => fetch(new URL(path, origin), { ...init, signal: AbortSignal.timeout(15_000) }); | |
| 38 | 43 | assert.equal((await request("/__seed")).status, 200); | |
| 44 | + | for (const [path, destination] of [["/developers", "https://docs.g1t.sh/reference/api/"], ["/openapi.json", "https://api.g1t.sh/openapi.json"], ["/mcp", "https://mcp.g1t.sh"]]) { | |
| 45 | + | const answer = await request(path, { redirect: "manual" }); | |
| 46 | + | assert.equal(answer.status, 308); | |
| 47 | + | assert.equal(answer.headers.get("location"), new URL(destination).href); | |
| 48 | + | } | |
| 49 | + | // Run Markdown first, then HTML at the same URL: the cache must not mix them. | |
| 50 | + | for (const path of ["/", "/__agent-readiness-missing"]) { | |
| 51 | + | for (const accept of ["text/markdown", "text/html", "text/markdown"]) { | |
| 52 | + | const answer = await request(path, { headers: { accept } }); | |
| 53 | + | assert.equal(answer.status, path === "/" ? 200 : 404); | |
| 54 | + | assert.ok(answer.headers.get("vary")?.toLowerCase().split(",").map((value) => value.trim()).includes("accept")); | |
| 55 | + | assert.ok(answer.headers.get("content-type")?.startsWith(accept)); | |
| 56 | + | const body = await answer.text(); | |
| 57 | + | if (accept === "text/markdown") { assert.match(body, /\[.*\]\(.*\/llms.txt\)/); assert.ok(body.length > 20); } | |
| 58 | + | else { assert.match(body, /<html/); if (path === "/") assert.match(body, /rel="canonical"/); } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | assert.equal((await request("/", { headers: { accept: "application/json" } })).status, 406); | |
| 62 | + | for (const accept of ["text/markdown", "text/html"]) { | |
| 63 | + | const answer = await request("/", { method: "HEAD", headers: { accept } }); | |
| 64 | + | assert.equal(answer.status, 200); | |
| 65 | + | assert.ok(answer.headers.get("content-type")?.startsWith(accept)); | |
| 66 | + | assert.equal(await answer.text(), ""); | |
| 67 | + | } | |
| 39 | 68 | const old = await request("/assets/app-old12345.css"); | |
| 40 | 69 | assert.equal(old.status, 200); | |
| 41 | 70 | assert.equal(old.headers.get("content-type"), "text/css"); | |
| ⋯ | |||
| 53 | 82 | assert.doesNotMatch(body, /_routes|wrong-build-cache/); | |
| 54 | 83 | } | |
| 55 | 84 | console.log("Deployment safety: old CSS survives, missing assets stay isolated, old navigations bypass cache and loaders."); | |
| 85 | + | console.log("Agent pages: Markdown/HTML negotiation, isolated caches, 404s, HEAD, canonical metadata and discovery redirects verified."); | |
| 56 | 86 | } finally { | |
| 57 | 87 | await runtime.dispose(); | |
| 58 | 88 | } | |
| 18 | 18 | import { serveUsercontent } from "./usercontent"; | |
| 19 | 19 | import { serveArchivedAsset } from "../app/lib/asset-archive"; | |
| 20 | 20 | import { needsBuildRefresh } from "../app/lib/build-version"; | |
| 21 | + | import { developerResource, pageRepresentation } from "../app/lib/agent-pages"; | |
| 21 | 22 | ||
| 22 | 23 | const loadBuild = () => import("virtual:react-router/server-build"); | |
| 23 | 24 | ||
| ⋯ | |||
| 107 | 108 | return proxyPackages(env, request); | |
| 108 | 109 | } | |
| 109 | 110 | // The documentation is its own site. | |
| 111 | + | const developer = ["GET", "HEAD"].includes(request.method) ? developerResource(pathname, addressesFor(env)) : null; | |
| 112 | + | if (developer) return Response.redirect(developer, 308); | |
| 110 | 113 | if (pathname === "/docs" || pathname.startsWith("/docs/")) { | |
| 111 | 114 | const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; | |
| 112 | 115 | const target = MOVED_DOCS[page] ?? "/"; | |
| ⋯ | |||
| 160 | 163 | const keyUrl = new URL(request.url); | |
| 161 | 164 | // Cached HTML and route data belong to the build that rendered them. | |
| 162 | 165 | keyUrl.searchParams.set("_g1t_build", import.meta.env.G1T_BUILD_ID); | |
| 166 | + | // Vary alone does not partition the Workers Cache API's explicit key. | |
| 167 | + | keyUrl.searchParams.set("_g1t_representation", pageRepresentation(request.headers.get("accept"))); | |
| 163 | 168 | if (visitorAsksFirst(request)) keyUrl.searchParams.set("_g1t_consent", "1"); | |
| 164 | 169 | const key = new Request(keyUrl, { method: "GET" }); | |
| 165 | 170 | const repository = PUBLIC_PROJECT.test(new URL(request.url).pathname) ? repositoryOfPage(new URL(request.url).pathname) : null; | |
| 1 | + | // Read-only production verification. --mcp uses the existing CLI login without logging it. | |
| 2 | + | // Run: node --use-system-ca scripts/check-agent-readiness.mjs [--mcp] | |
| 3 | + | import assert from "node:assert/strict"; | |
| 4 | + | import { execFileSync } from "node:child_process"; | |
| 5 | + | ||
| 6 | + | let failures = 0; | |
| 7 | + | async function check(name, run) { | |
| 8 | + | try { await run(); console.log(`PASS ${name}`); } | |
| 9 | + | catch (error) { failures++; console.error(`FAIL ${name}: ${error.message}`); } | |
| 10 | + | } | |
| 11 | + | async function get(url, headers = {}) { | |
| 12 | + | const response = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) }); | |
| 13 | + | return { response, body: await response.text() }; | |
| 14 | + | } | |
| 15 | + | const varies = (response) => (response.headers.get("vary") ?? "").toLowerCase().split(",").map((v) => v.trim()).includes("accept"); | |
| 16 | + | for (const [path, status] of [["/", 200], ["/__agent-readiness-nonexistent", 404]]) { | |
| 17 | + | for (const accept of ["text/markdown", "text/html", "text/html;q=0.2,text/markdown;q=1"]) { | |
| 18 | + | await check(`${path} Accept: ${accept}`, async () => { | |
| 19 | + | const { response, body } = await get(`https://g1t.sh${path}`, { accept }); | |
| 20 | + | assert.equal(response.status, status); | |
| 21 | + | assert.ok(varies(response), "Vary must contain Accept"); | |
| 22 | + | const markdown = accept !== "text/html"; | |
| 23 | + | assert.ok(response.headers.get("content-type")?.startsWith(markdown ? "text/markdown" : "text/html")); | |
| 24 | + | if (markdown) { assert.ok(body.length > 20); assert.match(body, /\[.*\]\(https:\/\/g1t.sh\/llms.txt\)/); } | |
| 25 | + | else assert.match(body, /<html/); | |
| 26 | + | }); | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | await check("unsupported homepage representation", async () => assert.equal((await get("https://g1t.sh/", { accept: "application/json" })).response.status, 406)); | |
| 30 | + | await check("homepage metadata", async () => { | |
| 31 | + | const { body } = await get("https://g1t.sh/", { accept: "text/html" }); | |
| 32 | + | assert.match(body, /<html[^>]*lang="en"/); | |
| 33 | + | assert.match(body, /<link[^>]*rel="canonical"[^>]*href="https:\/\/g1t.sh\/"/); | |
| 34 | + | assert.match(body, /property="og:image"/); | |
| 35 | + | assert.match(body, /property="og:type"/); | |
| 36 | + | const schemas = [...body.matchAll(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])); | |
| 37 | + | const organization = schemas.find((schema) => schema["@type"] === "Organization"); | |
| 38 | + | assert.equal(organization?.contactPoint?.email, "hey@flagon.io"); | |
| 39 | + | }); | |
| 40 | + | await check("llms.txt instructions and developer links", async () => { | |
| 41 | + | const { response, body } = await get("https://g1t.sh/llms.txt"); | |
| 42 | + | assert.equal(response.status, 200); | |
| 43 | + | assert.match(body, /^# g1t\s/m); | |
| 44 | + | assert.match(body, /## When to use g1t/); | |
| 45 | + | for (const url of ["https://api.g1t.sh/openapi.json", "https://mcp.g1t.sh/.well-known/oauth-protected-resource", "https://g1t.sh/settings/tokens"]) assert.ok(body.includes(url)); | |
| 46 | + | }); | |
| 47 | + | await check("OpenAPI scopes and unprefixed versioned API", async () => { | |
| 48 | + | const { body, response } = await get("https://api.g1t.sh/openapi.json"); | |
| 49 | + | assert.equal(response.status, 200); | |
| 50 | + | const spec = JSON.parse(body); | |
| 51 | + | assert.equal(spec.servers[0].url, "https://api.g1t.sh"); | |
| 52 | + | const scopes = spec.components.securitySchemes.oauth.flows.authorizationCode.scopes; | |
| 53 | + | const metadata = JSON.parse((await get("https://mcp.g1t.sh/.well-known/oauth-protected-resource")).body); | |
| 54 | + | assert.deepEqual(Object.keys(scopes).sort(), metadata.scopes_supported.sort()); | |
| 55 | + | assert.equal(spec.components.parameters.ApiVersion.name, "X-G1t-Api-Version"); | |
| 56 | + | assert.ok(spec.components.responses.RateLimited.headers["Retry-After"]); | |
| 57 | + | }); | |
| 58 | + | for (const path of ["/repos", "/v1/repos"]) await check(`REST ${path} version and limit headers`, async () => { | |
| 59 | + | const { response, body } = await get(`https://api.g1t.sh${path}`, { "X-G1t-Api-Version": "1" }); | |
| 60 | + | assert.equal(response.status, 200); | |
| 61 | + | assert.ok(Array.isArray(JSON.parse(body))); | |
| 62 | + | assert.equal(response.headers.get("x-g1t-api-version"), "1"); | |
| 63 | + | assert.equal(response.headers.get("ratelimit-policy"), '"anonymous";q=60;w=60'); | |
| 64 | + | }); | |
| 65 | + | await check("unsupported REST version fails", async () => assert.equal((await get("https://api.g1t.sh/repos", { "X-G1t-Api-Version": "not-a-version" })).response.status, 422)); | |
| 66 | + | for (const url of ["https://g1t.sh/developers", "https://g1t.sh/openapi.json", "https://g1t.sh/mcp", "https://g1t.sh/sitemap.xml", "https://g1t.sh/robots.txt", "https://docs.g1t.sh/reference/api/", "https://docs.g1t.sh/reference/rate-limits/", "https://docs.g1t.sh/guides/authentication/", "https://docs.g1t.sh/guides/cli/", "https://docs.g1t.sh/guides/self-hosting/", "https://g1t.sh/downloads/cli/latest.json"]) { | |
| 67 | + | await check(url, async () => { const { response, body } = await get(url); assert.equal(response.status, 200); assert.ok(body.length > 20); }); | |
| 68 | + | } | |
| 69 | + | await check("published versioning documentation", async () => assert.match((await get("https://docs.g1t.sh/reference/api/")).body, /X-G1t-Api-Version/)); | |
| 70 | + | await check("MCP still requires authorization", async () => { | |
| 71 | + | const response = await fetch("https://mcp.g1t.sh", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), signal: AbortSignal.timeout(20_000) }); | |
| 72 | + | assert.equal(response.status, 401); | |
| 73 | + | assert.match(response.headers.get("www-authenticate") ?? "", /resource_metadata/); | |
| 74 | + | await response.body?.cancel(); | |
| 75 | + | }); | |
| 76 | + | if (process.argv.includes("--mcp")) await check("authorized MCP initialize and tools/list", async () => { | |
| 77 | + | const token = execFileSync("g1t", ["auth", "token", "--hostname", "g1t.sh"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); | |
| 78 | + | for (const [id, method, params] of [[1, "initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "g1t-readiness-check", version: "1" } }], [2, "tools/list", {}]]) { | |
| 79 | + | const response = await fetch("https://mcp.g1t.sh", { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), signal: AbortSignal.timeout(20_000) }); | |
| 80 | + | assert.equal(response.status, 200); | |
| 81 | + | const result = await response.json(); | |
| 82 | + | assert.ok(!result.error, "MCP returned a protocol error"); | |
| 83 | + | if (method === "tools/list") assert.ok(result.result.tools.length > 0); | |
| 84 | + | else assert.ok(result.result.protocolVersion); | |
| 85 | + | } | |
| 86 | + | }); | |
| 87 | + | process.exitCode = failures ? 1 : 0; |
| 1 | + | // Run after building apps/api. All rate-limit exhaustion is local, never production. | |
| 2 | + | import assert from "node:assert/strict"; | |
| 3 | + | import { readFileSync } from "node:fs"; | |
| 4 | + | import { resolve } from "node:path"; | |
| 5 | + | import { Miniflare, convertV4MiniflareOptions } from "miniflare"; | |
| 6 | + | ||
| 7 | + | const root = resolve("apps/api/build"); | |
| 8 | + | const config = readFileSync("apps/api/wrangler.jsonc", "utf8"); | |
| 9 | + | const services = [...config.matchAll(/"binding":\s*"([^"]+)"\s*,\s*"service"/g)].map((m) => m[1]); | |
| 10 | + | let calls = 0; | |
| 11 | + | const runtime = new Miniflare(convertV4MiniflareOptions({ | |
| 12 | + | name: "api-agent-protocol", cf: false, modulesRoot: root, | |
| 13 | + | modules: [{ type: "ESModule", path: resolve(root, "index.js") }, { type: "CompiledWasm", path: resolve(root, "index_bg.wasm") }], | |
| 14 | + | compatibilityDate: "2026-09-26", | |
| 15 | + | serviceBindings: Object.fromEntries(services.map((name) => [name, async () => { calls++; return Response.json(null); }])), | |
| 16 | + | ratelimits: { | |
| 17 | + | API_ANONYMOUS_LIMIT: { namespace_id: "4401", simple: { limit: 60, period: 60 } }, | |
| 18 | + | API_TOKEN_LIMIT: { namespace_id: "4402", simple: { limit: 1000, period: 60 } }, | |
| 19 | + | }, | |
| 20 | + | })); | |
| 21 | + | try { | |
| 22 | + | const origin = await runtime.ready; | |
| 23 | + | const request = (path, init) => fetch(new URL(path, origin), { ...init, signal: AbortSignal.timeout(15_000) }); | |
| 24 | + | const openapi = await request("/openapi.json"); | |
| 25 | + | assert.equal(openapi.status, 200); | |
| 26 | + | assert.equal(openapi.headers.get("x-g1t-api-version"), "1"); | |
| 27 | + | assert.equal(openapi.headers.get("ratelimit-policy"), '"anonymous";q=60;w=60'); | |
| 28 | + | assert.equal(openapi.headers.get("ratelimit"), null, "no fabricated remaining count"); | |
| 29 | + | const spec = await openapi.json(); | |
| 30 | + | assert.ok(spec.components.securitySchemes.oauth.flows.authorizationCode.scopes["repo:read"]); | |
| 31 | + | assert.equal((await request("/", { headers: { "X-G1t-Api-Version": "1" } })).status, 200); | |
| 32 | + | assert.equal((await request("/v1/")).status, 200); | |
| 33 | + | const before = calls; | |
| 34 | + | assert.equal((await request("/repos/example/test/issues", { method: "POST", headers: { "X-G1t-Api-Version": "unsupported", "content-type": "application/json" }, body: "{}" })).status, 422); | |
| 35 | + | assert.equal(calls, before, "unsupported versions cannot execute a mutation"); | |
| 36 | + | const cors = await request("/repos", { method: "OPTIONS" }); | |
| 37 | + | assert.equal(cors.status, 204); | |
| 38 | + | assert.match(cors.headers.get("access-control-allow-headers"), /x-g1t-api-version/); | |
| 39 | + | assert.match(cors.headers.get("access-control-expose-headers"), /ratelimit-policy/); | |
| 40 | + | let exhausted; | |
| 41 | + | for (let i = 0; i < 65; i++) { | |
| 42 | + | const answer = await request("/"); | |
| 43 | + | await answer.text(); | |
| 44 | + | if (answer.status === 429) { exhausted = answer; break; } | |
| 45 | + | } | |
| 46 | + | assert.ok(exhausted, "local quota must be exhausted"); | |
| 47 | + | assert.equal(exhausted.headers.get("retry-after"), "60"); | |
| 48 | + | assert.equal(exhausted.headers.get("ratelimit"), '"anonymous";r=0;t=60'); | |
| 49 | + | assert.equal(exhausted.headers.get("ratelimit-policy"), '"anonymous";q=60;w=60'); | |
| 50 | + | console.log("API Worker: OpenAPI, version selection, mutation rejection, CORS, policy and actual 429 headers verified."); | |
| 51 | + | } finally { await runtime.dispose(); } |
Comments
Sign in to comment.