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.

g1t.sh
1,040 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! The public API: REST at api.g1t.sh and the MCP server at mcp.g1t.sh.
2//!
3//! One Worker, two hostnames. Both are thin adapters over the same
4//! operations (see [`operations::Op`]), which call the services that own
5//! the data. This Worker holds none.
6
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb977mod about;
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.8mod run_artifacts;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.9mod archives;
Merge branch 'worktree-agent-aaf03bdceac799c89'10mod addresses;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor11mod alerts;
12mod audit;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit13mod billing;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor14mod blobs;
Merge checks: statuses and check runs on every commit15mod checks;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r916mod customize;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9717mod deployments;
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.18mod telemetry;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca19mod deploy_keys;
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails20mod limits;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)21mod logs;
Merge branch 'mirroring' into artifacts-mode22mod mirrors;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor23mod mcp;
API: notifications over REST and MCP, with notifications scopes24mod notifications;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor25mod oauth;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R226mod oidc;
Merge packages: roles, Actions access, source label, soft delete, API27mod packages;
Merge main into Artifacts Phase 228mod folios;
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar29mod people;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor30mod openapi;
API: pinned projects over REST and MCP31mod pins;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9732mod projects;
Merge branch 'worktree-agent-a3abfcce648e87dca'33mod protection;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor34mod operations;
35mod renamed;
36#[cfg(test)]
37mod responses;
38mod rest;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge39mod rules;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor40mod runners;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar41mod security;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor42mod tools;
API and MCP for a workspace's personal access token rules, members' tokens and approvals43mod token_policy;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R244mod toolkit;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor45
Merge branch 'model-routing'46use g1t_contracts::billing::{FinishRunArgs, RunTokens};
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor47use g1t_contracts::identity::{
48 DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs,
49};
50use g1t_contracts::work::{
51 CheckRun, Mergeable, QueueState, ReportChecksArgs, ReportMergecheckArgs, ReportPlanArgs,
52 ReportQueueArgs, ReportReviewArgs,
53};
54use g1t_contracts::identity::AgentScope;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)55use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, User, Viewer};
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor56use g1t_kit::wire;
57use serde_json::{Value, json};
58use worker::{Context, Env, Method, Request, Response, Result, event};
59
60use operations::Services;
61
62fn method_name(method: Method) -> &'static str {
63 match method {
64 Method::Get => "GET",
65 Method::Post => "POST",
66 Method::Patch => "PATCH",
67 Method::Put => "PUT",
68 Method::Delete => "DELETE",
69 Method::Options => "OPTIONS",
70 Method::Head => "HEAD",
71 _ => "OTHER",
72 }
73}
74
75/// A JSON response. Every body the API sends has its keys in `snake_case`;
76/// the contracts it passes through are `camelCase`, so they are converted
77/// here, on the way out (see [`g1t_kit::wire`]). The OpenAPI document and
78/// the MCP protocol's own envelope keep the spelling their standards use.
79pub(crate) fn reply<T: serde::Serialize>(value: &T) -> Result<Response> {
80 Response::from_json(&wire::snake_case(serde_json::to_value(value)?))
81}
82
83/// The parts of a job's spec (`POST /actions/jobs/{job}/spec`) that are the
84/// workflow file, GitHub's contexts and event, and where to check out, all
85/// passed through as they are.
86const JOB_SPEC_AS_GIVEN: &[&str] = &[
Merge branch 'worktree-agent-a3abfcce648e87dca'87 "spec", "workflow", "github", "event", "contexts", "checkout", "permissions",
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor88];
89
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R290/// Puts the toolkit's variables in a job's spec: its runtime token, where
91/// the toolkit's services are, and where to ask for an OIDC token when the
92/// job may have one and this installation issues them. The `runtime` the
93/// actions service sent goes no further.
94fn with_runtime(spec: &mut Value, api: &str, oidc: bool) {
95 let Some(runtime) = spec.as_object_mut().and_then(|s| s.remove("runtime")) else { return };
96 let Some(token) = runtime["token"].as_str().filter(|t| !t.is_empty()) else { return };
97 let id_token = oidc && runtime["id_token"].as_bool() == Some(true);
98 let vars = toolkit::runtime_variables(api, token, id_token);
99 if let Some(variables) = spec.get_mut("variables").and_then(Value::as_object_mut) {
100 variables.extend(vars);
101 }
102}
103
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)104/// What a person whose account has not confirmed its email address may
105/// call: who they are, their addresses, and confirming one with the code
106/// from the email. Nothing over MCP.
107fn pending_may(method: &str, path: &str, on_mcp: bool) -> bool {
108 !on_mcp
109 && matches!(
110 (method, path.trim_end_matches('/')),
111 ("GET", "/user") | ("GET", "/user/emails") | ("POST", "/user/emails/confirm")
112 )
113}
114
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor115/// An error in the shape every endpoint uses.
116fn failure(failure: &Failure) -> Result<Response> {
117 Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status()))
118}
119
120fn fail(code: FailureCode, message: &str) -> Result<Response> {
121 failure(&Failure {
122 code,
123 message: message.to_owned(),
124 })
125}
126
127/// A request body as JSON. An empty or malformed body is no input.
128async fn json_body(request: &mut Request) -> Value {
129 request.json().await.unwrap_or(Value::Null)
130}
131
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'132/// Who a request's `Authorization: Bearer g1t_…` (or `token g1t_…`, as
133/// Octokit sends it) names. A missing token is an anonymous viewer; a
134/// wrong one is refused, so that a typo does not silently look signed out.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor135async fn authenticate(
136 request: &Request,
137 services: &Services,
138) -> Result<std::result::Result<Viewer, Response>> {
139 let header = request.headers().get("authorization")?.unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'140 let Some(token) = limits::bearer(&header) else {
141 return Ok(Ok(None));
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor142 };
143 let viewer: Viewer = g1t_kit::call(
144 &services.identity,
145 "user_for_access_token",
146 &TokenArgs {
147 token: token.to_owned(),
148 },
149 )
150 .await?;
151 if viewer.is_some() {
152 return Ok(Ok(viewer));
153 }
154 let mut response = fail(FailureCode::Unauthenticated, "Invalid access token.")?;
155 // Tells an MCP client where to sign in again.
156 response.headers_mut().set(
157 "www-authenticate",
Merge branch 'worktree-agent-aaf03bdceac799c89'158 &format!("{}, error=\"invalid_token\"", services.addresses.mcp_challenge()),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor159 )?;
160 Ok(Err(response))
161}
162
163/// Where everything is, for someone or something exploring the API.
Merge branch 'worktree-agent-aaf03bdceac799c89'164fn index(addresses: &addresses::Addresses) -> Value {
165 let api = &addresses.api;
166 let repo = format!("{api}/repos/{{owner}}/{{name}}");
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor167 json!({
168 "documentation_url": "https://docs.g1t.sh/reference/api/",
Merge branch 'worktree-agent-aaf03bdceac799c89'169 "openapi_url": format!("{api}/openapi.json"),
170 "mcp_url": addresses.mcp,
171 "current_user_url": format!("{api}/user"),
172 "workspaces_url": format!("{api}/workspaces"),
173 "repositories_url": format!("{api}/repos{{?q}}"),
174 "search_url": format!("{api}/search{{?q,type,page,per_page}}"),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor175 "repository_url": repo,
176 "repository_events_url": format!("{repo}/events{{?before}}"),
177 "labels_url": format!("{repo}/labels"),
178 "issues_url": format!("{repo}/issues{{?state,label}}"),
179 "issue_url": format!("{repo}/issues/{{number}}"),
180 "issue_comments_url": format!("{repo}/issues/{{number}}/comments"),
181 "pulls_url": format!("{repo}/pulls{{?state}}"),
182 "pull_url": format!("{repo}/pulls/{{number}}"),
183 "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"),
184 "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"),
185 "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"),
Merge branch 'worktree-agent-aaf03bdceac799c89'186 "device_code_url": format!("{api}/device/code"),
187 "device_token_url": format!("{api}/device/token"),
188 "oauth_metadata_url": format!("{api}/.well-known/oauth-authorization-server"),
189 "git_url": format!("{}/{{owner}}/{{name}}.git", addresses.site),
190 "integrations_url": format!("{api}/workspaces/{{workspace}}/integrations"),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor191 "context_url": format!("{repo}/context{{?reference}}"),
192 "import_issue_url": format!("{repo}/issues/import"),
Merge branch 'worktree-agent-aaf03bdceac799c89'193 "hooks_url": format!("{api}/hooks/{{integration}}"),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor194 })
195}
196
197// Signing in from a tool. Accounts are created, and passwords typed, only
198// in a browser; a tool gets its token by having a person approve a code.
199
200/// Passes a request from an outside system to its connection, as it came:
201/// its signature covers the exact bytes of the body.
202async fn receive_hook(request: &mut Request, services: &Services, id: &str) -> Result<Response> {
203 let headers: std::collections::HashMap<String, String> = request
204 .headers()
205 .entries()
206 .map(|(name, value)| (name.to_lowercase(), value))
207 .collect();
208 let body = request.text().await.unwrap_or_default();
209 // A push to GitHub with many commits makes a large payload.
210 let limit = if id == "github" { 10_000_000 } else { 1_000_000 };
211 if body.len() > limit {
212 return Ok(reply(&json!({ "message": "The body is too large." }))?.with_status(413));
213 }
214 // g1t's GitHub App has one webhook for every installation; it is
215 // checked against the app's own secret.
216 let (method, args) = if id == "github" {
217 ("github_receive", json!({ "headers": headers, "body": body }))
218 } else {
219 ("receive", json!({ "id": id, "headers": headers, "body": body }))
220 };
221 let received: g1t_contracts::integrations::Received =
222 g1t_kit::call(&services.integrations, method, &args).await?;
223 Ok(reply(&json!({ "message": received.message }))?.with_status(received.status))
224}
225
226async fn receive_stripe(request: &mut Request, env: &Env) -> Result<Response> {
227 let signature = request.headers().get("stripe-signature")?.unwrap_or_default();
228 let payload = request.text().await.unwrap_or_default();
229 if payload.len() > 1_000_000 || signature.is_empty() {
230 return Ok(reply(&json!({ "message": "Not a Stripe event." }))?.with_status(400));
231 }
232 let handled: g1t_contracts::Outcome<bool> = g1t_kit::call(
233 &env.service("BILLING")?,
234 "stripe_webhook",
235 &g1t_contracts::billing::StripeWebhookArgs { payload, signature },
236 )
237 .await?;
238 // A refusal is a 400, so Stripe shows it as failed; anything handled,
239 // or already handled, is a 200, so Stripe stops sending it.
240 Ok(match handled {
241 g1t_contracts::Outcome::Ok(_) => reply(&json!({ "received": true }))?,
242 g1t_contracts::Outcome::Fail(failure) => {
243 reply(&json!({ "message": failure.message }))?.with_status(400)
244 }
245 })
246}
247
248async fn device_code(request: &mut Request, services: &Services) -> Result<Response> {
249 let body = json_body(request).await;
250 let started: DeviceStart = g1t_kit::call(
251 &services.identity,
252 "device_start",
253 &DeviceStartArgs {
254 client_name: body["client_name"].as_str().unwrap_or_default().to_owned(),
255 },
256 )
257 .await?;
258 reply(&json!({
259 "device_code": started.device_code,
260 "user_code": started.user_code,
Merge branch 'worktree-agent-aaf03bdceac799c89'261 "verification_uri": format!("{}/device", services.addresses.site),
262 "verification_uri_complete": format!("{}/device?code={}", services.addresses.site, started.user_code),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor263 "expires_in": started.expires_in,
264 "interval": started.interval,
265 }))
266}
267
268async fn device_token(request: &mut Request, services: &Services) -> Result<Response> {
269 let body = json_body(request).await;
270 let claim: DeviceClaim = g1t_kit::call(
271 &services.identity,
272 "device_claim",
273 &DeviceClaimArgs {
274 device_code: body["device_code"].as_str().unwrap_or_default().to_owned(),
275 },
276 )
277 .await?;
278 reply(&match claim {
279 DeviceClaim::Approved { token, user } => json!({
280 "status": "approved",
281 "token": token,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar282 "display_username": user.display_username.clone().filter(|display| display.eq_ignore_ascii_case(&user.username)).unwrap_or_else(|| user.username.clone()),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor283 "username": user.username,
284 "verified": user.verified,
285 }),
286 DeviceClaim::Pending => json!({ "status": "pending" }),
287 DeviceClaim::Denied => json!({ "status": "denied" }),
288 DeviceClaim::Expired => json!({ "status": "expired" }),
289 })
290}
291
292/// A sandbox reporting on a run of an issue's commands, from before a pull
293/// request's checks were the workflows run on it.
294/// The run's own token, in the body, is the credential: it was given to
295/// that sandbox and to nothing else.
296async fn report_checks(
297 request: &mut Request,
298 services: &Services,
299 run_id: &str,
300) -> Result<Response> {
301 let body = json_body(request).await;
302 let reported: Outcome<CheckRun> = g1t_kit::call(
303 &services.work,
304 "report_checks",
305 &ReportChecksArgs {
306 run_id: run_id.to_owned(),
307 token: body["token"].as_str().unwrap_or_default().to_owned(),
308 results: serde_json::from_value(body["results"].clone()).unwrap_or_default(),
309 error: body["error"].as_str().map(str::to_owned),
310 skip: false,
311 },
312 )
313 .await?;
314 match reported {
315 Outcome::Ok(run) => reply(&json!({ "status": run.status })),
316 Outcome::Fail(refused) => failure(&refused),
317 }
318}
319
320/// A sandbox reporting one tested state of a merge queue. As with checks,
321/// the entry's own token is the credential.
322async fn report_queue(
323 request: &mut Request,
324 services: &Services,
325 entry_id: &str,
326) -> Result<Response> {
327 let body = json_body(request).await;
328 let reported: Outcome<QueueState> = g1t_kit::call(
329 &services.work,
330 "report_queue",
331 &ReportQueueArgs {
332 entry_id: entry_id.to_owned(),
333 token: body["token"].as_str().unwrap_or_default().to_owned(),
334 combined_commit: body["combinedCommit"].as_str().map(str::to_owned),
335 results: serde_json::from_value(body["results"].clone()).unwrap_or_default(),
336 error: body["error"].as_str().map(str::to_owned),
337 conflict_with: body["conflictWith"].as_u64().map(|n| n as u32),
338 conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(),
339 },
340 )
341 .await?;
342 match reported {
343 Outcome::Ok(state) => reply(&json!({ "state": state })),
344 Outcome::Fail(refused) => failure(&refused),
345 }
346}
347
348/// A sandbox reporting whether a pull request merges cleanly. As with
349/// checks, the probe's own token is the credential.
350async fn report_mergecheck(
351 request: &mut Request,
352 services: &Services,
353 pull_id: &str,
354) -> Result<Response> {
355 let body = json_body(request).await;
356 let reported: Outcome<Mergeable> = g1t_kit::call(
357 &services.work,
358 "report_mergecheck",
359 &ReportMergecheckArgs {
360 pull_id: pull_id.to_owned(),
361 token: body["token"].as_str().unwrap_or_default().to_owned(),
362 conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(),
363 error: body["error"].as_str().map(str::to_owned),
364 },
365 )
366 .await?;
367 match reported {
368 Outcome::Ok(state) => reply(&json!({ "mergeable": state })),
369 Outcome::Fail(refused) => failure(&refused),
370 }
371}
372
Merge branch 'worktree-agent-ac5b181a013e54348'373/// A backup's sandbox, passed on to the repos service, which holds the
374/// job (services/repos/src/backups.rs; the flow is in
375/// `g1t_contracts::backups`):
376///
377/// - `POST /backups/{job}/spec`: what to cut, and a read-only git credential
378/// - `PUT /backups/{job}/parts/{n}`: one part of the bundle, as bytes
379/// - `POST /backups/{job}/complete` with `{ refs, size, sha256, parts, fetched_bytes }`
380/// - `POST /backups/{job}/fail` with `{ error, fetched_bytes }`
381///
382/// Bodies are passed through as they are: snake_case already, and a
383/// bundle's refs are keyed by ref names, which must not be converted.
384async fn backup_job(request: &mut Request, services: &Services, method: &str, path: &str) -> Result<Response> {
385 use g1t_contracts::backups::TOKEN_HEADER;
386 let token = request.headers().get(TOKEN_HEADER)?.unwrap_or_default();
387 let rest = path.trim_start_matches("/backups/");
388 let (job, action) = rest.split_once('/').unwrap_or((rest, ""));
389 if job.is_empty() || token.is_empty() {
390 return fail(FailureCode::Unauthenticated, "A backup job's token is required.");
391 }
392 if method == "PUT" && action.starts_with("parts/") {
393 let bytes = request.bytes().await?;
394 if bytes.len() as u64 > g1t_contracts::backups::PART_BYTES {
395 return fail(FailureCode::Invalid, "A part holds 32 MiB at most.");
396 }
397 let headers = worker::Headers::new();
398 headers.set(TOKEN_HEADER, &token)?;
399 let mut init = worker::RequestInit::new();
400 init.with_method(Method::Put)
401 .with_headers(headers)
402 .with_body(Some(worker::js_sys::Uint8Array::from(bytes.as_slice()).into()));
403 let forwarded = Request::new_with_init(&format!("https://repos/backups/{job}/{action}"), &init)?;
404 let mut answered = services.repos.fetch_request(forwarded).await?;
405 return outcome_as_given(answered.json().await?);
406 }
407 let rpc = match (method, action) {
408 ("POST", "spec") => "backup_spec",
409 ("POST", "complete") => "backup_complete",
410 ("POST", "fail") => "backup_fail",
411 _ => return fail(FailureCode::NotFound, "No such endpoint."),
412 };
413 let mut body = json_body(request).await;
414 if !body.is_object() {
415 body = json!({});
416 }
417 body["job_id"] = json!(job);
418 body["token"] = json!(token);
419 let answered: Value = g1t_kit::call(&services.repos, rpc, &body).await?;
420 outcome_as_given(answered)
421}
422
423/// An `Outcome` from a service whose keys are already the API's: the value,
424/// or the failure in the shape every endpoint uses.
425fn outcome_as_given(answered: Value) -> Result<Response> {
426 match serde_json::from_value::<Outcome<Value>>(answered)? {
427 Outcome::Ok(value) => Response::from_json(&value),
428 Outcome::Fail(refused) => failure(&refused),
429 }
430}
431
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor432/// A sandbox reporting the review its agent wrote. As with checks, the
433/// run's own token is the credential.
434async fn report_review(
435 request: &mut Request,
436 services: &Services,
437 run_id: &str,
438) -> Result<Response> {
439 let body = json_body(request).await;
440 let reported: Outcome<bool> = g1t_kit::call(
441 &services.work,
442 "report_review",
443 &ReportReviewArgs {
444 run_id: run_id.to_owned(),
445 token: body["token"].as_str().unwrap_or_default().to_owned(),
446 verdict: serde_json::from_value(body["verdict"].clone()).unwrap_or(None),
447 body: body["body"].as_str().unwrap_or_default().to_owned(),
448 comments: serde_json::from_value(body["comments"].clone()).unwrap_or_default(),
449 model: body["model"].as_str().map(str::to_owned),
450 error: body["error"].as_str().map(str::to_owned),
451 },
452 )
453 .await?;
454 match reported {
455 Outcome::Ok(_) => reply(&json!({ "recorded": true })),
456 Outcome::Fail(refused) => failure(&refused),
457 }
458}
459
460/// A sandbox reporting the plan its agent wrote. As with checks, the
461/// plan's own token is the credential.
462async fn report_plan(
463 request: &mut Request,
464 services: &Services,
465 plan_id: &str,
466) -> Result<Response> {
467 let body = json_body(request).await;
468 let reported: Outcome<bool> = g1t_kit::call(
469 &services.work,
470 "report_plan",
471 &ReportPlanArgs {
472 plan_id: plan_id.to_owned(),
473 token: body["token"].as_str().unwrap_or_default().to_owned(),
474 summary: body["summary"].as_str().unwrap_or_default().to_owned(),
475 issues: serde_json::from_value(body["issues"].clone()).unwrap_or_default(),
476 error: body["error"].as_str().map(str::to_owned),
477 },
478 )
479 .await?;
480 match reported {
481 Outcome::Ok(_) => reply(&json!({ "recorded": true })),
482 Outcome::Fail(refused) => failure(&refused),
483 }
484}
485
486/// A sandbox reporting what its agent's run cost, so that the workspace
487/// it worked for is charged. As with checks, the run's own token is the
488/// credential.
489async fn report_usage(
490 request: &mut Request,
491 services: &Services,
492 run_id: &str,
493) -> Result<Response> {
494 let body = json_body(request).await;
495 let charged: Outcome<bool> = g1t_kit::call(
496 &services.billing,
497 "finish_run",
498 &FinishRunArgs {
499 run_id: run_id.to_owned(),
500 token: body["token"].as_str().unwrap_or_default().to_owned(),
501 cost_usd: body["cost_usd"].as_f64().unwrap_or_default(),
502 turns: body["turns"].as_u64().unwrap_or_default() as u32,
Merge branch 'model-routing'503 // What the harness counted; the agent rate is charged on no
504 // fewer, on a workspace's own model key too.
505 tokens: body.get("tokens").filter(|t| t.is_object()).map(|t| RunTokens {
506 input: t["input"].as_u64().unwrap_or_default(),
507 output: t["output"].as_u64().unwrap_or_default(),
508 cache_read: t["cache_read"].as_u64().unwrap_or_default(),
509 cache_write: t["cache_write"].as_u64().unwrap_or_default(),
510 }),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor511 },
512 )
513 .await?;
514 match charged {
515 Outcome::Ok(_) => reply(&json!({ "recorded": true })),
516 Outcome::Fail(refused) => failure(&refused),
517 }
518}
519
520async fn respond(mut request: Request, env: &Env) -> Result<Response> {
521 let method = method_name(request.method());
522 if method == "OPTIONS" {
523 return Ok(Response::empty()?.with_status(204));
524 }
525 let url = request.url()?;
526 // Paths carry no version. An earlier form began with `/v1`, which is
527 // still accepted so that nothing already written against it breaks.
528 let path = match url.path().strip_prefix("/v1") {
529 Some(rest) if rest.is_empty() || rest.starts_with('/') => rest.to_owned(),
530 _ => url.path().to_owned(),
531 };
532 let mut services = Services::new(env)?;
Merge branch 'worktree-agent-aaf03bdceac799c89'533 // MCP is a host of its own hosted, and may be a path on this one
534 // self-hosted (addresses.rs).
535 let on_mcp = services.addresses.mcp_path(&url).is_some();
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor536
537 // Stripe reporting to billing. Signed with the secret of the endpoint
538 // billing registered; the body goes through exactly as received, since
539 // the signature covers its bytes.
540 if method == "POST" && !on_mcp && path == "/stripe/webhook" {
541 return receive_stripe(&mut request, env).await;
542 }
543
544 // Outside systems reporting to a connection. They sign what they send
545 // with the connection's own secret, which is not a g1t token, so this
546 // comes before anything that would read one.
547 if method == "POST" && !on_mcp
548 && let Some(id) = path.strip_prefix("/hooks/").filter(|id| !id.is_empty() && !id.contains('/')) {
549 return receive_hook(&mut request, &services, id).await;
550 }
551
552 // A sandbox building a deployment, reporting with its build's token,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.553 // which is not a g1t token, or a container app's Worker reporting how
554 // long its container ran, with its own token. The body goes through as
555 // it is: it can carry a Worker's bundled code.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor556 if method == "POST" && !on_mcp
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.557 && let Some(rest) = path
558 .strip_prefix("/deployments/jobs/")
559 .map(|rest| format!("jobs/{rest}"))
560 .or_else(|| path.strip_prefix("/deployments/apps/").filter(|rest| rest.ends_with("/meter")).map(|rest| format!("apps/{rest}")))
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor561 {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.562 let target = format!("https://deployments/{rest}");
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor563 let body = request.bytes().await?;
564 let headers = worker::Headers::new();
565 headers.set("content-type", "application/json")?;
566 let mut init = worker::RequestInit::new();
567 init.with_method(Method::Post)
568 .with_headers(headers)
569 .with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into()));
570 let mut answer = env
571 .service("DEPLOYMENTS")?
572 .fetch_request(Request::new_with_init(&target, &init)?)
573 .await?;
574 // A fresh response: a fetched one's headers cannot be changed, and
575 // every response gets the API's own on the way out.
576 let status = answer.status_code();
577 let bytes = answer.bytes().await?;
578 let bytes = match serde_json::from_slice::<Value>(&bytes) {
579 Ok(body) => serde_json::to_vec(&wire::snake_case(body))?,
580 Err(_) => bytes,
581 };
582 return Ok(Response::from_bytes(bytes)?
583 .with_status(status)
584 .with_headers({
585 let headers = worker::Headers::new();
586 headers.set("content-type", "application/json")?;
587 headers
588 }));
589 }
590
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2591 // The services GitHub's toolkit calls from inside a job, with its
592 // runtime token, and the links they hand out (toolkit.rs).
593 if !on_mcp && method == "POST"
594 && let Some(rest) = path.strip_prefix("/twirp/")
595 {
596 let (service, rpc) = rest.split_once('/').unwrap_or((rest, ""));
597 let (service, rpc) = (service.to_owned(), rpc.to_owned());
598 return toolkit::twirp(request, env, &services, &service, &rpc).await;
599 }
600 if !on_mcp && let Some(rest) = path.strip_prefix("/actions/toolkit/_apis/artifactcache/") {
601 let rest = rest.to_owned();
602 return toolkit::cache_v1(request, env, &services, method, &rest).await;
603 }
604 if !on_mcp && let Some(token) = path.strip_prefix("/actions/toolkit/blobs/") {
605 let token = token.to_owned();
606 return toolkit::blob(request, env, &services, method, &token).await;
607 }
608 // g1t as an OIDC issuer for workflow jobs (oidc.rs).
609 if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") {
610 return oidc::handle(&request, env, &services, &path).await;
611 }
612
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor613 // A sandbox's artifacts and cache, with its job's token, which is not a
614 // g1t token either.
615 if !on_mcp
616 && let Some(rest) = path.strip_prefix("/actions/jobs/")
617 && (rest.contains("/artifacts") || rest.ends_with("/cache") || rest.contains("/cache/uploads"))
618 {
619 let rest = rest.to_owned();
620 return blobs::for_job(request, env, &services, method, &rest).await;
621 }
622
623 // A self-hosted runner, with a registration token or its own
624 // credential, neither of which is a g1t access token.
625 if method == "POST"
626 && !on_mcp
627 && path.starts_with("/runners/")
628 && let Some(response) = runners::handle(&mut request, &services, &path).await?
629 {
630 return Ok(response);
631 }
632
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails633 // Per token, or per address without one (limits.rs).
634 if let Some(limited) = limits::limited(&request, env, method, &path, on_mcp).await? {
635 return Ok(limited);
636 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor637 let viewer = match authenticate(&request, &services).await? {
638 Ok(viewer) => viewer,
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails639 Err(refused) => return Ok(limits::wrong_token(&request, env, on_mcp).await?.unwrap_or(refused)),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor640 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)641 // A person who has not confirmed their email address: who they are,
642 // their addresses, and confirming one, nothing else (REST or MCP).
643 if viewer.as_ref().is_some_and(User::awaits_confirmation) && !pending_may(method, &path, on_mcp) {
644 return fail(
645 FailureCode::Forbidden,
646 &g1t_contracts::accounts::confirm_email_first(&services.addresses.site),
647 );
648 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor649 services.audit = audit::AuditContext::of(&request, on_mcp);
650 // An agent's token: what it may do comes with it, on the composite
651 // identity identity resolved it to.
652 if let Some(acting) = viewer.as_ref().and_then(|viewer| viewer.acting.as_ref()) {
653 services.scope = Some(acting.scope.clone());
654 } else if viewer.as_ref().is_some_and(|viewer| viewer.kind == PrincipalKind::Agent) {
655 let header = request.headers().get("authorization")?.unwrap_or_default();
656 let token = header.split_once(' ').map(|(_, token)| token.trim()).unwrap_or_default();
657 let scope: Option<AgentScope> = g1t_kit::call(
658 &services.identity,
659 "agent_scope",
660 &TokenArgs {
661 token: token.to_owned(),
662 },
663 )
664 .await?;
665 // A scope is what lets an agent's token do anything at all.
666 let Some(scope) = scope else {
667 return fail(FailureCode::Unauthenticated, "Invalid access token.");
668 };
669 services.scope = Some(scope);
670 }
671 if let Some(response) = oauth::handle(&mut request, &services, method, &path).await? {
672 return Ok(response);
673 }
674 if on_mcp {
675 return mcp::handle(request, &services, &viewer).await;
676 }
677
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)678 // Workflow logs to download: a run's as a zip, a job's as text (logs.rs).
679 if method == "GET" {
680 let text = url.query_pairs().any(|(name, value)| name == "format" && value == "text");
681 if let Some(wanted) = logs::wanted(&path, text) {
682 return logs::download(&services, &viewer, wanted).await;
683 }
684 }
685
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor686 match (method, path.trim_end_matches('/')) {
Merge branch 'worktree-agent-aaf03bdceac799c89'687 ("GET", "") => return reply(&index(&services.addresses)),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor688 ("GET", "/openapi.json") => return Response::from_json(&openapi::document()),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2689 // One of a run's artifacts downloaded by name (the run's artifacts
690 // are listed by the REST route in rest.rs).
691 ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor692 let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect();
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2693 if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() {
Merge branch 'worktree-agent-a3abfcce648e87dca'694 // A workflow job's token reaches its own repository only.
695 if viewer
696 .as_ref()
697 .and_then(|user| user.token.as_deref())
698 .is_some_and(|token| !token.reaches(&format!("{owner}/{repo}")))
699 {
700 return fail(FailureCode::NotFound, "No such run.");
701 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2702 return blobs::download(env, &services, &viewer, owner, repo, run, name).await;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor703 }
704 }
705 ("POST", "/device/code") => return device_code(&mut request, &services).await,
706 ("POST", "/device/token") => return device_token(&mut request, &services).await,
707 // Where a pull request lives, for a tool that knows only its fork.
708 ("GET", path) if path.starts_with("/pulls/") && !path[7..].contains('/') => {
709 let located: Outcome<Value> = g1t_kit::call(
710 &services.work,
711 "locate_pull",
712 &json!({ "id": &path[7..], "viewer": viewer }),
713 )
714 .await?;
715 return match located {
716 Outcome::Ok(value) => reply(&value),
717 Outcome::Fail(refused) => failure(&refused),
718 };
719 }
720 ("POST", path) if path.starts_with("/mergechecks/") => {
721 let pull_id = path.trim_start_matches("/mergechecks/").to_owned();
722 return report_mergecheck(&mut request, &services, &pull_id).await;
723 }
Merge branch 'worktree-agent-ac5b181a013e54348'724 // A sandbox making a repository's nightly backup. The job's own
725 // token, in its header, is the credential.
726 (method, path) if path.starts_with("/backups/") => {
727 return backup_job(&mut request, &services, method, path).await;
728 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor729 ("POST", path) if path.starts_with("/queue/") => {
730 let entry_id = path.trim_start_matches("/queue/").to_owned();
731 return report_queue(&mut request, &services, &entry_id).await;
732 }
733 // A sandbox running a GitHub Actions job: fetching the job, and
734 // reporting how it goes. The job's own token is the credential.
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'735 // A sandbox downloading an action's archive that g1t keeps
736 // (actions' registry.rs). The job's own token is the credential.
737 ("POST", path) if path.starts_with("/actions/jobs/") && path.ends_with("/archive") => {
738 let job = path.trim_start_matches("/actions/jobs/").trim_end_matches("/archive").to_owned();
739 let body = json_body(&mut request).await;
740 let kept: Outcome<Value> = g1t_kit::call(
741 &services.actions,
742 "job_action_archive",
743 &json!({ "job": job, "token": body["token"], "report": body["report"] }),
744 )
745 .await?;
746 let kept = match kept {
747 Outcome::Ok(kept) => kept,
748 Outcome::Fail(refused) => return failure(&refused),
749 };
750 let bucket = env.bucket("ACTIONS_CACHE")?;
751 let object = kept["object"].as_str().unwrap_or_default();
752 let Some(stored) = bucket.get(object).execute().await? else {
753 return fail(FailureCode::NotFound, "The archive is gone.");
754 };
755 let Some(body) = stored.body() else {
756 return fail(FailureCode::NotFound, "The archive is gone.");
757 };
758 let mut response = Response::from_body(body.response_body()?)?;
759 response.headers_mut().set("content-type", "application/gzip")?;
760 return Ok(response);
761 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor762 ("POST", path) if path.starts_with("/actions/jobs/") => {
763 let rest = path.trim_start_matches("/actions/jobs/");
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts764 // `/action`: where to fetch another repository's action from (on
765 // g1t, with a read token for a private one, or GitHub).
766 let (job, method) = match (rest.strip_suffix("/spec"), rest.strip_suffix("/action")) {
767 (Some(job), _) => (job.to_owned(), "job_spec"),
768 (_, Some(job)) => (job.to_owned(), "job_action"),
769 _ => (rest.to_owned(), "job_report"),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor770 };
771 let body = json_body(&mut request).await;
772 let answered: Outcome<Value> = g1t_kit::call(
773 &services.actions,
774 method,
775 &json!({ "job": job, "token": body["token"], "report": body["report"] }),
776 )
777 .await?;
778 return match answered {
779 // A job's spec is the workflow and its contexts as GitHub
780 // has them; only g1t's own keys around them are converted.
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2781 Outcome::Ok(value) => {
782 let mut spec = wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN);
783 with_runtime(&mut spec, &services.addresses.api, oidc::configured(env));
784 Response::from_json(&spec)
785 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor786 Outcome::Fail(refused) => failure(&refused),
787 };
788 }
789 // A sandbox reporting its agent run's steps, cost and end. As with
790 // checks, the run's own token, in the body, is the credential.
791 ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/report") => {
792 let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/report");
793 let mut body = json_body(&mut request).await;
794 if !body.is_object() {
795 body = json!({});
796 }
797 body["runId"] = json!(run_id);
798 let reported: Outcome<Value> = g1t_kit::call(&services.work, "report_run", &body).await?;
799 return match reported {
800 Outcome::Ok(status) => reply(&json!({ "status": status })),
801 Outcome::Fail(refused) => failure(&refused),
802 };
803 }
804 // What a run's agent learned, as memory candidates; the same token.
805 ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/learned") => {
806 let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/learned");
807 let body = json_body(&mut request).await;
808 let learned = json!({
809 "runId": run_id,
810 "token": body["token"].as_str().unwrap_or_default(),
811 "items": body["items"].as_array().cloned().unwrap_or_default(),
812 });
813 let captured: Outcome<Value> = g1t_kit::call(&services.work, "report_learned", &learned).await?;
814 return match captured {
815 Outcome::Ok(captured) => reply(&captured),
816 Outcome::Fail(refused) => failure(&refused),
817 };
818 }
819 // How sure a run's agent is of its change; the same token.
820 ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/confidence") => {
821 let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/confidence");
822 let body = json_body(&mut request).await;
823 let said = json!({
824 "runId": run_id,
825 "token": body["token"].as_str().unwrap_or_default(),
826 "confidence": body["confidence"].as_str().unwrap_or_default(),
827 "uncertainAbout": body["uncertain_about"]
828 .as_array()
829 .map(|items| items.iter().filter_map(Value::as_str).collect::<Vec<_>>())
830 .unwrap_or_default(),
831 });
832 let recorded: Outcome<Value> = g1t_kit::call(&services.work, "report_confidence", &said).await?;
833 return match recorded {
834 Outcome::Ok(recorded) => reply(&json!({ "recorded": recorded })),
835 Outcome::Fail(refused) => failure(&refused),
836 };
837 }
838 ("POST", path) if path.starts_with("/checks/") => {
839 let run_id = path.trim_start_matches("/checks/").to_owned();
840 return report_checks(&mut request, &services, &run_id).await;
841 }
842 ("POST", path) if path.starts_with("/runs/") && path.ends_with("/usage") => {
843 let run_id = path
844 .trim_start_matches("/runs/")
845 .trim_end_matches("/usage")
846 .to_owned();
847 return report_usage(&mut request, &services, &run_id).await;
848 }
849 ("POST", path) if path.starts_with("/plans/") => {
850 let plan_id = path.trim_start_matches("/plans/").to_owned();
851 return report_plan(&mut request, &services, &plan_id).await;
852 }
853 ("POST", path) if path.starts_with("/reviews/") => {
854 let run_id = path.trim_start_matches("/reviews/").to_owned();
855 return report_review(&mut request, &services, &run_id).await;
856 }
857 _ => {}
858 }
859
860 let query: Vec<(String, String)> = url
861 .query_pairs()
862 .map(|(name, value)| (name.into_owned(), value.into_owned()))
863 .collect();
864 let body = if method == "GET" {
865 Value::Null
866 } else {
867 snake_case_keys(json_body(&mut request).await)
868 };
869 let Some((route, input)) = rest::resolve(method, &path, &query, body) else {
870 return fail(FailureCode::NotFound, "No such endpoint.");
871 };
872 match audit::run(route.op, &services, &viewer, &input).await? {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.873 // A download is a redirect to its signed link, as GitHub's is: an
874 // artifact's, and a repository's tarball and zipball.
875 Outcome::Ok(value) if route.op.redirects() => {
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2876 match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) {
877 Some(url) => Response::redirect_with_status(url, 302),
878 None => reply(&value),
879 }
880 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts881 // A deleted comment has nothing to say, as GitHub's says nothing.
882 Outcome::Ok(_) if route.no_content() => Ok(Response::empty()?.with_status(204)),
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.883 Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, route.op.as_given())),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor884 // A token without the scope a call needs is told which one.
885 Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) {
886 (FailureCode::Forbidden, Some(scope)) => Ok(reply(&json!({
887 "error": {
888 "code": refused.code,
889 "message": refused.message,
890 "needed_scope": scope.as_str(),
891 }
892 }))?
893 .with_status(403)),
894 _ => failure(&refused),
895 },
896 }
897}
898
899/// Request bodies take the same keys as the MCP tools, `snake_case`, as
900/// responses use; the `camelCase` spelling is accepted too.
901fn snake_case_keys(body: Value) -> Value {
902 let Value::Object(fields) = body else {
903 return body;
904 };
905 let mut out = serde_json::Map::new();
906 for (key, value) in fields {
907 let mut snake = String::with_capacity(key.len() + 4);
908 for c in key.chars() {
909 if c.is_ascii_uppercase() {
910 snake.push('_');
911 snake.push(c.to_ascii_lowercase());
912 } else {
913 snake.push(c);
914 }
915 }
916 // A key given in both spellings keeps the snake_case one.
917 if snake != key && out.contains_key(&snake) {
918 continue;
919 }
920 out.insert(snake, value);
921 }
922 Value::Object(out)
923}
924
925#[cfg(test)]
926mod tests {
927 use super::snake_case_keys;
928 use serde_json::json;
929
930 #[test]
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)931 fn an_unconfirmed_account_may_only_see_itself_and_confirm_its_address() {
932 assert!(super::pending_may("GET", "/user", false));
933 assert!(super::pending_may("GET", "/user/emails/", false));
934 assert!(super::pending_may("POST", "/user/emails/confirm", false));
935 assert!(!super::pending_may("POST", "/user/emails", false));
936 assert!(!super::pending_may("POST", "/workspaces", false));
937 assert!(!super::pending_may("GET", "/repos/acme/rocket", false));
938 assert!(!super::pending_may("POST", "/user/emails/confirm", true));
939 assert!(!super::pending_may("POST", "/", true));
940 }
941
942 #[test]
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2943 fn a_job_spec_gets_the_toolkits_variables() {
944 // As the actions service sends it, converted as the API does.
945 let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
946 let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN);
947 super::with_runtime(&mut spec, "https://api.g1t.sh", true);
948 assert!(spec.get("runtime").is_none(), "the runner never sees it");
949 let vars = &spec["variables"];
950 assert_eq!(vars["GITHUB_SHA"], "abc");
951 assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s");
952 assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/");
953 assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "h.p.s");
954 // No OIDC key here: no OIDC variables, whatever the job may do.
955 let mut spec = g1t_kit::wire::snake_case_keeping(sent, super::JOB_SPEC_AS_GIVEN);
956 super::with_runtime(&mut spec, "https://api.g1t.sh", false);
957 assert!(spec["variables"].get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none());
958 assert_eq!(spec["variables"]["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/");
959 }
960
961 #[test]
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor962 fn camel_case_keys_are_accepted() {
963 assert_eq!(
964 snake_case_keys(json!({ "countAgentApprovals": false, "title": "x" })),
965 json!({ "count_agent_approvals": false, "title": "x" })
966 );
967 }
968
969 #[test]
970 fn snake_case_wins_when_both_are_given() {
971 assert_eq!(
972 snake_case_keys(json!({ "keep_issue_open": true, "keepIssueOpen": false })),
973 json!({ "keep_issue_open": true })
974 );
975 }
976}
977
978// The API is called from browsers too: the reference's explorer, and apps
979// built on g1t. It carries no cookies, so any origin may call it.
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.980/// The signal an OTLP path sends (`/otlp/v1/traces`, `logs` or `metrics`),
981/// if it is one.
982fn otlp_signal(path: &str) -> Option<&'static str> {
983 let path = path.strip_prefix("/v1").filter(|rest| rest.starts_with('/')).unwrap_or(path);
984 match path.trim_end_matches('/') {
985 "/otlp/v1/traces" => Some("traces"),
986 "/otlp/v1/logs" => Some("logs"),
987 "/otlp/v1/metrics" => Some("metrics"),
988 _ => None,
989 }
990}
991
992/// OpenTelemetry sent to the API's own address: a self-hosted install's
993/// only door, and a second one hosted. It goes to the telemetry service as
994/// it came, which checks the project's ingest token itself (not a g1t
995/// token), and its answer comes back as it is, CORS headers included.
996async fn forward_otlp(mut request: Request, env: &Env, signal: &str) -> Result<Response> {
997 let method = request.method();
998 let headers = worker::Headers::new();
999 for name in ["authorization", "x-g1t-token", "content-type", "content-encoding"] {
1000 if let Some(value) = request.headers().get(name)? {
1001 headers.set(name, &value)?;
1002 }
1003 }
1004 let mut init = worker::RequestInit::new();
1005 init.with_method(method.clone()).with_headers(headers);
1006 if method == Method::Post {
1007 let body = request.bytes().await?;
1008 init.with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into()));
1009 }
1010 let target = format!("https://telemetry/v1/{signal}");
1011 let mut answer = env.service("TELEMETRY")?.fetch_request(Request::new_with_init(&target, &init)?).await?;
1012 // A fresh response, with the service's headers: a fetched one's cannot
1013 // be changed, and nothing should be added to them.
1014 let status = answer.status_code();
1015 let headers = worker::Headers::new();
1016 for (name, value) in answer.headers().entries() {
1017 headers.set(&name, &value)?;
1018 }
1019 let bytes = answer.bytes().await?;
1020 let response = if bytes.is_empty() { Response::empty()? } else { Response::from_bytes(bytes)? };
1021 Ok(response.with_status(status).with_headers(headers))
1022}
1023
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1024#[event(fetch)]
1025async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> {
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.1026 // OTLP passes through untouched, before any g1t token is read.
1027 if let Some(signal) = otlp_signal(&request.path()) {
1028 return forward_otlp(request, &env, signal).await;
1029 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1030 let mut response = respond(request, &env).await?;
1031 let headers = response.headers_mut();
1032 headers.set("access-control-allow-origin", "*")?;
1033 headers.set(
1034 "access-control-allow-headers",
1035 "authorization, content-type",
1036 )?;
1037 headers.set("access-control-allow-methods", "GET, POST, PATCH, OPTIONS")?;
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails1038 headers.set("access-control-expose-headers", "www-authenticate, retry-after")?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1039 Ok(response)
1040}

This file's history is long; its oldest lines are credited to the oldest commit read.