Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Improve agent discovery, Markdown negotiation and API contracts

syntaqxcommitted Parenteb483daBrowse files
20 files+528−150/20 viewed
+14−6
2323 mod telemetry;
2424 mod deploy_keys;
2525 mod limits;
26+mod version;
2627 mod logs;
2728 mod mcp;
2829 mod notifications;
434435 }
435436 }
436437
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> {
438439 let method = method_name(request.method());
439440 if method == "OPTIONS" {
440441 return Ok(Response::empty()?.with_status(204));
450451 // MCP is a host of its own hosted, and may be a path on this one
451452 // self-hosted (addresses.rs).
452453 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+ }
453457
454458 // Stripe reporting to billing. Signed with the secret of the endpoint
455459 // billing registered; the body goes through exactly as received, since
557561 }
558562
559563 // 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? {
561565 return Ok(limited);
562566 }
563567 let viewer = match authenticate(&request, &services).await? {
564568 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)),
566570 };
567571 // A person who has not confirmed their email address: who they are,
568572 // their addresses, and confirming one, nothing else (REST or MCP).
884888 if let Some(signal) = otlp_signal(&request.path()) {
885889 return forward_otlp(request, &env, signal).await;
886890 }
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)?; }
888895 let headers = response.headers_mut();
896+ if !on_mcp { headers.set(version::HEADER, version::CURRENT)?; }
889897 headers.set("access-control-allow-origin", "*")?;
890898 headers.set(
891899 "access-control-allow-headers",
892− "authorization, content-type",
900+ "authorization, content-type, x-g1t-api-version",
893901 )?;
894902 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")?;
896904 Ok(response)
897905 }
+51−4
2121 pub const ANONYMOUS: &str = "API_ANONYMOUS_LIMIT";
2222 pub const TOKEN: &str = "API_TOKEN_LIMIT";
2323
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+
2453 /// Paths a sandbox or runner reports to with its own token in the body:
2554 /// many sandboxes share an address, and none of them is a person.
2655 const REPORTS: &[&str] = &[
81110 }
82111
83112 /// 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>> {
85114 let header = request.headers().get("authorization")?.unwrap_or_default();
86115 let token = bearer(&header);
87116 if !counts(method, path, token.is_some()) {
89118 }
90119 let address = request.headers().get("cf-connecting-ip")?;
91120 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() {
93124 return too_many(token.is_some()).map(Some);
94125 }
95126 Ok(None)
96127 }
97128
98129 /// 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>> {
100131 let address = request.headers().get("cf-connecting-ip")?;
101132 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() {
103136 return too_many(false).map(Some);
104137 }
105138 Ok(None)
110143 use super::*;
111144
112145 #[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]
113160 fn tokens_are_counted_by_their_hash_and_others_by_address() {
114161 let (binding, tok) = key(Some("g1t_secret"), Some("203.0.113.9"), false);
115162 assert_eq!(binding, TOKEN);
+73−3
11161116 // Public data can be read without a token; everything else needs one.
11171117 let scope: Vec<&str> = scope_for(op.name()).map(|scope| scope.as_str()).into_iter().collect();
11181118 let security = if op.needs_user() {
1119− json!([{ "token": scope }])
1119+ json!([{ "token": [] }, { "oauth": scope }])
11201120 } else {
1121− json!([{ "token": scope }, {}])
1121+ json!([{ "token": [] }, { "oauth": scope }, {}])
11221122 };
11231123 let (tool, action) = crate::tools::TOOLS
11241124 .iter()
12771277 entry[route.method.to_lowercase()] = operation(route);
12781278 }
12791279 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();
12801297 let tags: Vec<Value> = SECTIONS
12811298 .iter()
12821299 .map(|(name, description, ops)| {
12881305 })
12891306 })
12901307 .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"];
12921309 json!({
12931310 "openapi": "3.1.0",
12941311 "info": {
13021319 "tags": tags,
13031320 "paths": paths,
13041321 "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+ },
13051345 "securitySchemes": {
13061346 "token": {
13071347 "type": "http",
13081348 "scheme": "bearer",
13091349 "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.",
13101350 },
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+ },
13111360 },
13121361 "schemas": {
13131362 "Error": {
13381387 use super::*;
13391388
13401389 #[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]
13411411 fn every_route_is_documented_once() {
13421412 let document = document();
13431413 let mut ids = Vec::new();
+19−0
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+}
+20−0
3737
3838 The response has more entries than shown here.
3939
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+
4060 ## Authentication
4161
4262 A token is needed to change anything, and to see what is private. Send an
+26−0
99 normally reaches. Each counts requests over 60 seconds, approximately: a
1010 client can sometimes get a few more through before it is limited.
1111
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+
1238 ## Limits
1339
1440 | Where | Counted by | Requests a minute |
+0−0

Binary or large file; its contents are not shown.

+4−0
77 import { NonceContext } from "./lib/nonce";
88 import { makeNonce, pagePolicy } from "./lib/page-headers";
99 import { recordHandler } from "./lib/perf.server";
10+import { agentPage } from "./lib/agent-pages";
1011
1112 // React Router's own server entry, plus the timing of every loader and
1213 // action for the Server-Timing header (lib/perf.server.ts).
3940 routerContext: EntryContext,
4041 _loadContext: RouterContextProvider,
4142 ) {
43+ const agent = agentPage(request, responseStatusCode, responseHeaders);
44+ if (agent) return agent;
4245 // https://httpwg.org/specs/rfc9110.html#HEAD
4346 if (request.method.toUpperCase() === "HEAD") {
47+ responseHeaders.set("Content-Type", "text/html");
4448 return new Response(null, {
4549 status: responseStatusCode,
4650 headers: responseHeaders,
+48−0
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+});
+52−0
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+}
+1−0
99 export const COMPANY = {
1010 name: "Flagon, Inc.",
1111 url: "https://www.flagon.io",
12+ email: "hey@flagon.io",
1213 /** Who founded it, for search engines' structured data only (structured-data.ts). */
1314 founder: "Chase Pierce",
1415 };
+7−0
77
88 type Tag = Record<string, string>;
99
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+
1017 function tags(list: unknown[]): Map<string, string> {
1118 const found = new Map<string, string>();
1219 for (const tag of list as Tag[]) {
+1−0
130130 const image = og ? cardUrl(pathname, version, og) : null;
131131 return [
132132 { title: meta.title },
133+ ...(pathname === "/" ? [{ tagName: "link" as const, rel: "canonical", href: `${site}/` }] : []),
133134 { name: "description", content: description },
134135 { property: "og:site_name", content: "g1t" },
135136 { property: "og:type", content: meta.type ?? "website" },
+9−0
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+});
+3−2
55 */
66 import type { MetaDescriptor } from "react-router";
77
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";
1010
1111 export function organization(): MetaDescriptor {
1212 return {
1515 "@type": "Organization",
1616 name: COMPANY.name,
1717 url: COMPANY.url,
18+ contactPoint: { "@type": "ContactPoint", email: COMPANY.email, contactType: "customer support" },
1819 founder: { "@type": "Person", name: COMPANY.founder },
1920 brand: { "@type": "Brand", name: "g1t", url: SITE, sameAs: SOCIAL.map((account) => account.url) },
2021 },
+27−0
1919 and working. You never ask for, see, or send the person's password. Accounts
2020 are created and approved only in their browser.
2121
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+
2249 ## Set someone up
2350
2451 1. **Start a sign-in.**
+30−0
1919 await env.DOWNLOADS.put('web-assets/assets/app-old12345.css', ${JSON.stringify(css)}, {httpMetadata:{contentType:'text/css'}});
2020 const cache = new URL('/pricing.data', request.url);
2121 cache.searchParams.set('_g1t_build', ${JSON.stringify(build)});
22+ cache.searchParams.set('_g1t_representation', 'html');
2223 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())}}));
2324 return new Response('seeded');
2425 }
3132 compatibilityDate: config.compatibility_date,
3233 compatibilityFlags: config.compatibility_flags,
3334 r2Buckets: ["DOWNLOADS"],
35+ serviceBindings: {
36+ IDENTITY: async () => Response.json(null),
37+ PROJECTS: async () => Response.json({ ok: true, value: [] }),
38+ },
3439 }));
3540 try {
3641 const origin = await runtime.ready;
3742 const request = (path, init) => fetch(new URL(path, origin), { ...init, signal: AbortSignal.timeout(15_000) });
3843 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+ }
3968 const old = await request("/assets/app-old12345.css");
4069 assert.equal(old.status, 200);
4170 assert.equal(old.headers.get("content-type"), "text/css");
5382 assert.doesNotMatch(body, /_routes|wrong-build-cache/);
5483 }
5584 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.");
5686 } finally {
5787 await runtime.dispose();
5888 }
+5−0
1818 import { serveUsercontent } from "./usercontent";
1919 import { serveArchivedAsset } from "../app/lib/asset-archive";
2020 import { needsBuildRefresh } from "../app/lib/build-version";
21+import { developerResource, pageRepresentation } from "../app/lib/agent-pages";
2122
2223 const loadBuild = () => import("virtual:react-router/server-build");
2324
107108 return proxyPackages(env, request);
108109 }
109110 // 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);
110113 if (pathname === "/docs" || pathname.startsWith("/docs/")) {
111114 const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
112115 const target = MOVED_DOCS[page] ?? "/";
160163 const keyUrl = new URL(request.url);
161164 // Cached HTML and route data belong to the build that rendered them.
162165 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")));
163168 if (visitorAsksFirst(request)) keyUrl.searchParams.set("_g1t_consent", "1");
164169 const key = new Request(keyUrl, { method: "GET" });
165170 const repository = PUBLIC_PROJECT.test(new URL(request.url).pathname) ? repositoryOfPage(new URL(request.url).pathname) : null;
+87−0
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;
+51−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.