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
189 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.

README: run the core forge locally with Docker Compose; forks described as copies, not copy-on-write, until Cloudflare says otherwise1//! Device sign-in (RFC 8628): how an agent or command-line tool gets an
2//! access token without ever seeing a password.
3//!
4//! The tool starts a request and shows the person a short code and a URL.
5//! The person signs in on the website, or registers there, and approves the
6//! code. The tool polls until that happens and receives a token. Account
7//! creation and passwords stay in the browser, where they can be protected.
8
9use g1t_contracts::identity::*;
10use g1t_contracts::time::{SQL_NOW, sql_after};
11use g1t_contracts::{FailureCode, Outcome, User};
12use serde::Deserialize;
13use worker::Result;
14
15use crate::{Identity, crypto};
16
17const EXPIRES_IN_SECONDS: u64 = 15 * 60;
18const POLL_INTERVAL_SECONDS: u32 = 5;
19/// No vowels, so a code never spells a word, and nothing easily confused.
20const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
21
22#[derive(Deserialize)]
23struct DeviceRow {
24 user_code: String,
25 client_name: String,
26 status: String,
27 user_id: Option<String>,
28}
29
30/// Eight letters as `XXXX-XXXX`.
31fn new_user_code() -> String {
32 let mut random = [0u8; 8];
33 getrandom::getrandom(&mut random).expect("no source of randomness");
34 let letters: String = random
35 .iter()
36 .map(|byte| USER_CODE_ALPHABET[*byte as usize % USER_CODE_ALPHABET.len()] as char)
37 .collect();
38 format!("{}-{}", &letters[..4], &letters[4..])
39}
40
41/// A user code as stored, however it was typed.
42fn normalize(user_code: &str) -> String {
43 let letters: String = user_code
44 .chars()
45 .filter(char::is_ascii_alphabetic)
46 .map(|c| c.to_ascii_uppercase())
47 .collect();
48 match letters.len() {
49 8 => format!("{}-{}", &letters[..4], &letters[4..]),
50 _ => letters,
51 }
52}
53
54impl Identity {
55 pub async fn device_start(&self, a: DeviceStartArgs) -> Result<DeviceStart> {
56 let device_code = crypto::random_hex(32);
57 let user_code = new_user_code();
58 let client_name: String = match a.client_name.trim() {
59 "" => "An application".to_owned(),
60 name => name.chars().take(60).collect(),
61 };
62 self.db
63 .prepare(format!(
64 "INSERT INTO device_codes (id, user_code, client_name, expires_at)
65 VALUES (?, ?, ?, {})",
66 sql_after(EXPIRES_IN_SECONDS)
67 ))
68 .bind(&[
69 crypto::sha256_hex(&device_code).into(),
70 user_code.as_str().into(),
71 client_name.into(),
72 ])?
73 .run()
74 .await?;
75 Ok(DeviceStart {
76 device_code,
77 user_code,
78 expires_in: EXPIRES_IN_SECONDS as u32,
79 interval: POLL_INTERVAL_SECONDS,
80 })
81 }
82
83 /// The pending, unexpired request with this user code.
84 async fn pending_device(&self, user_code: &str) -> Result<Option<DeviceRow>> {
85 self.db
86 .prepare(format!(
87 "SELECT user_code, client_name, status, user_id FROM device_codes
88 WHERE user_code = ? AND status = 'pending' AND expires_at > {SQL_NOW}"
89 ))
90 .bind(&[normalize(user_code).into()])?
91 .first::<DeviceRow>(None)
92 .await
93 }
94
95 pub async fn device_lookup(&self, a: DeviceLookupArgs) -> Result<Option<DeviceRequest>> {
96 Ok(self
97 .pending_device(&a.user_code)
98 .await?
99 .map(|row| DeviceRequest {
100 user_code: row.user_code,
101 client_name: row.client_name,
102 }))
103 }
104
105 pub async fn device_resolve(&self, a: DeviceResolveArgs) -> Result<Outcome<bool>> {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)106 // A token for an account that cannot use it yet (emails.rs).
107 if a.approve && a.user.awaits_confirmation() {
108 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before signing in to an application."));
109 }
README: run the core forge locally with Docker Compose; forks described as copies, not copy-on-write, until Cloudflare says otherwise110 let Some(row) = self.pending_device(&a.user_code).await? else {
111 return Ok(Outcome::fail(
112 FailureCode::NotFound,
113 "That code is not valid or has expired. Start again from the application.",
114 ));
115 };
116 self.db
117 .prepare("UPDATE device_codes SET status = ?, user_id = ? WHERE user_code = ?")
118 .bind(&[
119 if a.approve { "approved" } else { "denied" }.into(),
120 a.user.id.into(),
121 row.user_code.into(),
122 ])?
123 .run()
124 .await?;
125 Ok(Outcome::Ok(a.approve))
126 }
127
128 pub async fn device_claim(&self, a: DeviceClaimArgs) -> Result<DeviceClaim> {
129 let id = crypto::sha256_hex(&a.device_code);
130 let row = self
131 .db
132 .prepare(format!(
133 "SELECT user_code, client_name, status, user_id FROM device_codes
134 WHERE id = ? AND expires_at > {SQL_NOW}"
135 ))
136 .bind(&[id.as_str().into()])?
137 .first::<DeviceRow>(None)
138 .await?;
139 let Some(row) = row else {
140 return Ok(DeviceClaim::Expired);
141 };
142 let user_id = match (row.status.as_str(), row.user_id) {
143 ("pending", _) => return Ok(DeviceClaim::Pending),
144 ("approved", Some(user_id)) => user_id,
145 _ => {
146 self.forget_device(&id).await?;
147 return Ok(DeviceClaim::Denied);
148 }
149 };
150 // A device code yields exactly one token.
151 self.forget_device(&id).await?;
152 let Some(user) = self
153 .find_user(
154 "SELECT id, username, email_verified_at IS NOT NULL AS verified
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)155 FROM users WHERE id = ? AND deleted_at IS NULL",
README: run the core forge locally with Docker Compose; forks described as copies, not copy-on-write, until Cloudflare says otherwise156 &user_id,
157 )
158 .await?
159 else {
160 return Ok(DeviceClaim::Expired);
161 };
162 let created = self
163 .create_access_token(CreateAccessTokenArgs {
164 user: User { ..user.clone() },
165 name: row.client_name,
166 ttl_seconds: None,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers167 // A tool a person signed in to themselves acts as them:
168 // every permission, set out as any token's are, so it can
169 // be narrowed under Settings, Access tokens like the rest.
170 scopes: Some(g1t_contracts::scopes::everything().iter().map(|scope| scope.as_str().to_owned()).collect()),
README: run the core forge locally with Docker Compose; forks described as copies, not copy-on-write, until Cloudflare says otherwise171 listed: false,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.172 repo: None,
README: run the core forge locally with Docker Compose; forks described as copies, not copy-on-write, until Cloudflare says otherwise173 })
174 .await?;
175 Ok(DeviceClaim::Approved {
176 token: created.token,
177 user,
178 })
179 }
180
181 async fn forget_device(&self, id: &str) -> Result<()> {
182 self.db
183 .prepare("DELETE FROM device_codes WHERE id = ?")
184 .bind(&[id.into()])?
185 .run()
186 .await?;
187 Ok(())
188 }
189}

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