Merge remote-tracking branch 'origin/main' into workspace-shell
20 files+587−630/20 viewed
| 333 | 333 | }) | |
| 334 | 334 | } | |
| 335 | 335 | ||
| 336 | − | /// `POST /twirp/{service}/{method}`. | |
| 337 | − | pub async fn twirp(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 336 | + | /// `POST /twirp/{service}/{method}`. A failure inside is logged and | |
| 337 | + | /// answered as Twirp's `internal`, with its cause. | |
| 338 | + | pub async fn twirp(request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 339 | + | match twirp_inner(request, env, services, service, method).await { | |
| 340 | + | Ok(response) => Ok(response), | |
| 341 | + | Err(error) => twirp_error("internal", &failed(&format!("POST /twirp/{service}/{method}"), &error)), | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | async fn twirp_inner(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 338 | 346 | let token = bearer(&request); | |
| 339 | 347 | let Some(job) = runtime_job(&token) else { | |
| 340 | 348 | return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| ⋯ | |||
| 502 | 510 | } | |
| 503 | 511 | ||
| 504 | 512 | fn query(request: &Request, name: &str) -> Option<String> { | |
| 505 | − | request.url().ok()?.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned()) | |
| 513 | + | query_in(&request.url().ok()?, name) | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | fn query_in(url: &worker::Url, name: &str) -> Option<String> { | |
| 517 | + | url.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned()) | |
| 506 | 518 | } | |
| 507 | 519 | ||
| 508 | 520 | /// The part a chunk of the older protocol is, from its `Content-Range`: | |
| ⋯ | |||
| 559 | 571 | Some(Some(range)) | |
| 560 | 572 | } | |
| 561 | 573 | ||
| 574 | + | /// What a lookup of the older protocol answers: 200 with the entry, 204 | |
| 575 | + | /// for a miss (which the toolkit's client and sccache read as "not | |
| 576 | + | /// cached"), or the refusal's status. | |
| 577 | + | pub fn lookup_answer(found: Outcome<Option<CacheHit>>, version: &str, api: &str) -> (u16, Option<Value>) { | |
| 578 | + | match found { | |
| 579 | + | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => ( | |
| 580 | + | 200, | |
| 581 | + | Some(json!({ | |
| 582 | + | "cacheKey": key, | |
| 583 | + | "cacheVersion": version, | |
| 584 | + | "scope": "", | |
| 585 | + | "creationTime": created_at, | |
| 586 | + | "archiveLocation": blob_url(api, &blob), | |
| 587 | + | })), | |
| 588 | + | ), | |
| 589 | + | // No entry, or one without a download link (no ACTIONS_KEY): a miss. | |
| 590 | + | Outcome::Ok(_) => (204, None), | |
| 591 | + | Outcome::Fail(refused) => (refused.code.http_status(), Some(json!({ "message": refused.message, "error": { "message": refused.message } }))), | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | /// Logs a toolkit request that failed inside g1t, and says what to tell | |
| 596 | + | /// its client: the cause, so a job's log shows more than a bare 500. | |
| 597 | + | fn failed(route: &str, error: &worker::Error) -> String { | |
| 598 | + | worker::console_error!("toolkit: {route} failed: {error}"); | |
| 599 | + | format!("g1t could not answer this: {error}") | |
| 600 | + | } | |
| 601 | + | ||
| 562 | 602 | /// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it. | |
| 563 | − | pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 603 | + | /// A failure inside is logged and answered as a 500 with its cause. | |
| 604 | + | pub async fn cache_v1(request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 605 | + | match cache_v1_inner(request, env, services, method, rest).await { | |
| 606 | + | Ok(response) => Ok(response), | |
| 607 | + | Err(error) => plain_error(500, &failed(&format!("{method} {CACHE_PATH}_apis/artifactcache/{rest}"), &error)), | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | async fn cache_v1_inner(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 564 | 612 | let token = bearer(&request); | |
| 565 | 613 | let Some(job) = runtime_job(&token) else { | |
| 566 | 614 | return plain_error(401, "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| ⋯ | |||
| 577 | 625 | let version = query(&request, "version").unwrap_or_default(); | |
| 578 | 626 | let args = CacheLookupArgs { job, token, key: key.clone(), restore: restore.to_vec(), version: Some(version.clone()) }; | |
| 579 | 627 | let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?; | |
| 580 | − | match found { | |
| 581 | − | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => Response::from_json(&json!({ | |
| 582 | − | "cacheKey": key, | |
| 583 | − | "cacheVersion": version, | |
| 584 | − | "scope": "", | |
| 585 | − | "creationTime": created_at, | |
| 586 | − | "archiveLocation": blob_url(&services.addresses.api, &blob), | |
| 587 | − | })), | |
| 588 | − | Outcome::Ok(_) => Ok(Response::empty()?.with_status(204)), | |
| 589 | − | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 628 | + | match lookup_answer(found, &version, &services.addresses.api) { | |
| 629 | + | (status, Some(body)) => Ok(Response::from_json(&body)?.with_status(status)), | |
| 630 | + | (status, None) => Ok(Response::empty()?.with_status(status)), | |
| 590 | 631 | } | |
| 591 | 632 | } | |
| 592 | 633 | ("POST", ["caches"]) => { | |
| ⋯ | |||
| 736 | 777 | } | |
| 737 | 778 | ||
| 738 | 779 | /// `/actions/toolkit/blobs/{token}`: GET or HEAD a download, PUT an upload. | |
| 739 | − | pub async fn blob(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 780 | + | /// A failure inside is logged and answered as Azure's `InternalError`. | |
| 781 | + | pub async fn blob(request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 782 | + | match blob_inner(request, env, services, method, token).await { | |
| 783 | + | Ok(response) => Ok(response), | |
| 784 | + | // The token is a credential: the route is logged without it. | |
| 785 | + | Err(error) => azure_error(500, "InternalError", &failed(&format!("{method} /actions/toolkit/blobs/…"), &error)), | |
| 786 | + | } | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | async fn blob_inner(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 740 | 790 | let opened: Outcome<BlobGrant> = g1t_kit::call(&services.actions, "blob_open", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?; | |
| 741 | 791 | let grant = match opened { | |
| 742 | 792 | Outcome::Ok(grant) => grant, | |
| ⋯ | |||
| 1086 | 1136 | assert_eq!(runtime_job("deadbeef"), None); | |
| 1087 | 1137 | } | |
| 1088 | 1138 | ||
| 1139 | + | /// sccache 0.18's storage check, at server start: a lookup of | |
| 1140 | + | /// `sccache/.sccache_check`. The actions service answers a miss with | |
| 1141 | + | /// `Ok(None)`, `{"ok":true,"value":null}`, which was read back as a | |
| 1142 | + | /// malformed outcome, and every lookup that missed was a 500 | |
| 1143 | + | /// ("Server startup failed: cache storage failed to read"). | |
| 1144 | + | #[test] | |
| 1145 | + | fn sccaches_first_lookup_misses_with_a_204() { | |
| 1146 | + | let url = worker::Url::parse( | |
| 1147 | + | "https://api.g1t.sh/actions/toolkit/_apis/artifactcache/cache?keys=sccache/.sccache_check&version=sccache-v0.18.0", | |
| 1148 | + | ) | |
| 1149 | + | .unwrap(); | |
| 1150 | + | assert_eq!(query_in(&url, "keys").as_deref(), Some("sccache/.sccache_check")); | |
| 1151 | + | assert_eq!(query_in(&url, "version").as_deref(), Some("sccache-v0.18.0")); | |
| 1152 | + | ||
| 1153 | + | // As the actions service replies (`g1t_kit::reply`), and the API | |
| 1154 | + | // reads it (`g1t_kit::call`). | |
| 1155 | + | let wire = serde_json::to_string(&Outcome::<Option<CacheHit>>::Ok(None)).unwrap(); | |
| 1156 | + | assert_eq!(wire, r#"{"ok":true,"value":null}"#); | |
| 1157 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::read_answer("cache_lookup", &wire).unwrap(); | |
| 1158 | + | assert_eq!(lookup_answer(found, "sccache-v0.18.0", "https://api.g1t.sh"), (204, None)); | |
| 1159 | + | ||
| 1160 | + | // Once saved, the same lookup is a hit with its download link. | |
| 1161 | + | let hit = CacheHit { | |
| 1162 | + | key: "sccache/.sccache_check".into(), | |
| 1163 | + | object: "c/repo_1/cache_1".into(), | |
| 1164 | + | size: 13, | |
| 1165 | + | created_at: "2026-10-08T12:00:00.000Z".into(), | |
| 1166 | + | blob: Some("tok.sig".into()), | |
| 1167 | + | }; | |
| 1168 | + | let wire = serde_json::to_string(&Outcome::Ok(Some(hit))).unwrap(); | |
| 1169 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::read_answer("cache_lookup", &wire).unwrap(); | |
| 1170 | + | let (status, body) = lookup_answer(found, "sccache-v0.18.0", "https://api.g1t.sh"); | |
| 1171 | + | let body = body.unwrap(); | |
| 1172 | + | assert_eq!(status, 200); | |
| 1173 | + | assert_eq!(body["cacheKey"], "sccache/.sccache_check"); | |
| 1174 | + | assert_eq!(body["cacheVersion"], "sccache-v0.18.0"); | |
| 1175 | + | assert_eq!(body["archiveLocation"], "https://api.g1t.sh/actions/toolkit/blobs/tok.sig"); | |
| 1176 | + | ||
| 1177 | + | // A refusal keeps its status and says why. | |
| 1178 | + | let refused = Outcome::<Option<CacheHit>>::fail(FailureCode::Unauthenticated, "That job is not running."); | |
| 1179 | + | let (status, body) = lookup_answer(refused, "v", "https://api.g1t.sh"); | |
| 1180 | + | assert_eq!((status, body.unwrap()["message"].as_str()), (401, Some("That job is not running."))); | |
| 1181 | + | ||
| 1182 | + | // An answer that does not read names its method and the cause. | |
| 1183 | + | let unread = g1t_kit::read_answer::<Outcome<CacheHit>>("cache_lookup", r#"{"ok":true,"value":null}"#).unwrap_err(); | |
| 1184 | + | assert!(unread.to_string().contains("cache_lookup answered with what could not be read"), "{unread}"); | |
| 1185 | + | } | |
| 1186 | + | ||
| 1089 | 1187 | #[test] | |
| 1090 | 1188 | fn a_job_is_told_where_the_toolkit_s_services_are() { | |
| 1091 | 1189 | let vars = runtime_variables("https://api.g1t.sh", "tok", false); | |
| 12 | 12 | hyphens, up to 39 characters. | |
| 13 | 13 | ||
| 14 | 14 | Before you can do anything else, you [confirm your email | |
| 15 | − | address](#confirming-your-email-address) with the code g1t emails you. | |
| 15 | + | address](#confirming-your-email-address) with the code g1t emails you, | |
| 16 | + | unless you signed up from the link in an invite g1t emailed to that address | |
| 17 | + | (see [invites from your inbox](#invites-from-your-inbox)). | |
| 16 | 18 | ||
| 17 | 19 | Accounts can only be created in a browser. There is no API for it, by | |
| 18 | 20 | design: it keeps passwords out of scripts and agents, and lets g1t protect | |
| ⋯ | |||
| 130 | 132 | ||
| 131 | 133 | 1. **No account yet**: sign up on the page. When the invite was sent to | |
| 132 | 134 | your address, the email field is filled in and locked. Choose a | |
| 133 | − | username (one is suggested from your address) and a password, then | |
| 135 | + | username (one is suggested from your address) and a password. If you | |
| 136 | + | opened the page from the invite email itself, the address is already | |
| 137 | + | confirmed and you go straight in (see | |
| 138 | + | [invites from your inbox](#invites-from-your-inbox)); otherwise | |
| 134 | 139 | [confirm the address](#confirming-your-email-address) with the code g1t | |
| 135 | − | emails it, even though the invite came there: an invite link can be | |
| 136 | − | forwarded, so it does not prove the inbox is yours. Or select | |
| 137 | − | **Continue with GitHub**: the invite rides along, and the account uses | |
| 138 | − | the invited address when GitHub has verified it too, in which case no | |
| 139 | − | confirmation is needed. | |
| 140 | + | emails it. Or select **Continue with GitHub**: the invite rides along, | |
| 141 | + | and the account uses the invited address when GitHub has verified it | |
| 142 | + | too, in which case no confirmation is needed. | |
| 140 | 143 | 2. **The address already has an account**: select **Sign in to accept**. | |
| 141 | 144 | After you sign in, the invite is accepted for you. | |
| 142 | 145 | 3. **Signed in as someone else**: an invite sent to one address works only | |
| ⋯ | |||
| 161 | 164 | An expired, revoked or used invite says which, and who sent it, so you | |
| 162 | 165 | can ask them for a new one; or ask for access from the same page. | |
| 163 | 166 | ||
| 167 | + | ### Invites from your inbox | |
| 168 | + | ||
| 169 | + | When g1t emails an invite to an address (an invite you make for someone, | |
| 170 | + | an owner's invite into a workspace or a repository, or an approved | |
| 171 | + | [request for access](#asking-for-access)), the link in that email carries a | |
| 172 | + | `proof` that only the email has: `g1t.sh/invite/<code>?proof=…`. Opening | |
| 173 | + | the link shows that you can read that inbox, so: | |
| 174 | + | ||
| 175 | + | - the invite page says the address is confirmed because you came from the | |
| 176 | + | invite email, and the email field stays locked to it; | |
| 177 | + | - your new account starts with the address confirmed: no code is sent, and | |
| 178 | + | you land in the workspace or repository the invite was for straight | |
| 179 | + | away. | |
| 180 | + | ||
| 181 | + | Anything else confirms the address the usual way, after you sign up: the | |
| 182 | + | code typed at [g1t.sh/register](https://g1t.sh/register), an invite link | |
| 183 | + | copied from **Settings → Invites** (whoever made the invite sees the code, | |
| 184 | + | never the proof), an invite made for anyone with the link, or an invite | |
| 185 | + | email sent before this existed. The proof is tied to one invite and its | |
| 186 | + | address, and stops working when the invite is used, revoked or expires. | |
| 187 | + | ||
| 164 | 188 | ### Invite links for a group | |
| 165 | 189 | ||
| 166 | 190 | g1t sometimes hands one link to a group: an event's judges, readers of a | |
| ⋯ | |||
| 209 | 233 | An owner can invite an email address straight into a workspace from its | |
| 210 | 234 | People page; see [members and roles](/guides/workspaces/#members-and-roles). | |
| 211 | 235 | When the address has no g1t account, the invite makes the account, which | |
| 212 | − | joins the workspace once it confirms its email address, and it uses one | |
| 213 | − | invite. Inviting someone who is | |
| 236 | + | joins the workspace once it confirms its email address (at once when it | |
| 237 | + | was made from the invite email's link), and it uses one invite. Inviting someone who is | |
| 214 | 238 | already on g1t costs nothing. | |
| 215 | 239 | ||
| 216 | 240 | ### Need more invites? | |
| ⋯ | |||
| 245 | 269 | ## Confirming your email address | |
| 246 | 270 | ||
| 247 | 271 | A new account confirms its email address before it can do anything else on | |
| 248 | − | g1t. Right after you sign up, g1t emails the address from `noreply@g1t.sh` | |
| 272 | + | g1t, unless it already has (it was made with GitHub, or from the link in | |
| 273 | + | its invite email). Right after you sign up, g1t emails the address from `noreply@g1t.sh` | |
| 249 | 274 | with two ways to confirm it, either one enough: | |
| 250 | 275 | ||
| 251 | 276 | - a **six-digit code**, shown large in the email (and in its subject, so a | |
| ⋯ | |||
| 294 | 319 | is one GitHub has verified, so GitHub has already proved the inbox is | |
| 295 | 320 | yours, and no code is sent. | |
| 296 | 321 | ||
| 322 | + | ### Addresses an invite email has confirmed | |
| 323 | + | ||
| 324 | + | An account made from the link in the invite g1t emailed to its address | |
| 325 | + | starts confirmed the same way: following that link proved the inbox is | |
| 326 | + | yours. It works only for the address the invite was sent to, and only from | |
| 327 | + | the email's own link; see [invites from your inbox](#invites-from-your-inbox). | |
| 328 | + | ||
| 297 | 329 | ### Accounts that never confirmed | |
| 298 | 330 | ||
| 299 | 331 | Accounts are confirmed once and stay confirmed. An account made before this | |
| 333 | 333 | ||
| 334 | 334 | The email names you and the workspace and links to the invite's page. | |
| 335 | 335 | Someone new signs up right there, with the invited address filled in, and | |
| 336 | − | joins once they confirm it with the code g1t emails them; someone with an | |
| 337 | − | account signs in. Either way they land in the workspace as a member, with | |
| 336 | + | joins at once when they opened the page from that email (it proves the | |
| 337 | + | address is theirs), or otherwise once they confirm it with the code g1t | |
| 338 | + | emails them; someone with an account signs in. Either way they land in the workspace as a member, with | |
| 338 | 339 | a one-time welcome. Until a new account confirms its address, its invite | |
| 339 | 340 | shows as **confirming their email** under the members, and you can still | |
| 340 | 341 | revoke it. See |
| 6 | 6 | HAVE_AN_INVITE, | |
| 7 | 7 | INVITES_CONTACT, | |
| 8 | 8 | cleanCode, | |
| 9 | + | cleanProof, | |
| 10 | + | invitePath, | |
| 11 | + | inviteSignUpCopy, | |
| 9 | 12 | inviteFor, | |
| 10 | 13 | inviteLink, | |
| 11 | 14 | inviteState, | |
| ⋯ | |||
| 45 | 48 | assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`); | |
| 46 | 49 | }); | |
| 47 | 50 | ||
| 51 | + | const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8"; | |
| 52 | + | ||
| 53 | + | test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => { | |
| 54 | + | assert.equal(cleanProof(PROOF), PROOF); | |
| 55 | + | assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF); | |
| 56 | + | assert.equal(cleanProof("not-a-proof"), null); | |
| 57 | + | assert.equal(cleanProof("abc"), null); | |
| 58 | + | assert.equal(cleanProof("a".repeat(500)), null); | |
| 59 | + | assert.equal(cleanProof(null), null); | |
| 60 | + | assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`); | |
| 61 | + | assert.equal(invitePath(CODE, null), `/invite/${CODE}`); | |
| 62 | + | assert.equal(invitePath(CODE), `/invite/${CODE}`); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => { | |
| 66 | + | const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null }; | |
| 67 | + | const proven = inviteSignUpCopy({ ...base, emailProven: true }); | |
| 68 | + | assert.equal(proven.intro, "You join Flagon, Inc. as soon as you create it."); | |
| 69 | + | assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/); | |
| 70 | + | assert.match(proven.hint, /confirmed already/); | |
| 71 | + | assert.doesNotMatch(proven.hint, /code/); | |
| 72 | + | ||
| 73 | + | // No proof (a code typed in, or a link passed on): nothing new is said. | |
| 74 | + | const plain = inviteSignUpCopy(base); | |
| 75 | + | assert.equal(plain.intro, "You join Flagon, Inc. as soon as you confirm your email."); | |
| 76 | + | assert.equal(plain.confirmed, null); | |
| 77 | + | assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start."); | |
| 78 | + | ||
| 79 | + | // An invite for anyone with the code has no address to prove. | |
| 80 | + | const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null }); | |
| 81 | + | assert.equal(open.confirmed, null); | |
| 82 | + | assert.equal(open.intro, "It takes a minute."); | |
| 83 | + | assert.equal(open.hint, "We email it a code to confirm it before you start."); | |
| 84 | + | ||
| 85 | + | const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true }); | |
| 86 | + | assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it."); | |
| 87 | + | }); | |
| 88 | + | ||
| 48 | 89 | test("a pasted link or code is tidied to the code", () => { | |
| 49 | 90 | assert.equal(cleanCode(CODE), CODE); | |
| 50 | 91 | assert.equal(cleanCode(` ${CODE} `), CODE); | |
| 65 | 65 | return text.replace(/\s+/g, "").slice(0, 80); | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | /** | |
| 69 | + | * The `proof` an invite email's link carries, tidied: hex, or null for | |
| 70 | + | * anything else. Identity decides whether it is the invite's own; this only | |
| 71 | + | * keeps junk out of what is passed on and put back into a form. | |
| 72 | + | */ | |
| 73 | + | export function cleanProof(raw: string | null | undefined): string | null { | |
| 74 | + | const text = (raw ?? "").trim().toLowerCase(); | |
| 75 | + | return /^[0-9a-f]{16,128}$/.test(text) ? text : null; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** An invite's page, keeping the email's proof when there is one. */ | |
| 79 | + | export function invitePath(code: string, proof?: string | null): string { | |
| 80 | + | const path = `/invite/${encodeURIComponent(code)}`; | |
| 81 | + | return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | type Proven = { | |
| 85 | + | /** The bound address in full, or null for an invite to anyone with the code. */ | |
| 86 | + | address: string | null; | |
| 87 | + | emailProven: boolean; | |
| 88 | + | workspace: { name: string } | null; | |
| 89 | + | repository: { name: string } | null; | |
| 90 | + | }; | |
| 91 | + | ||
| 92 | + | /** | |
| 93 | + | * What signing up on an invite's page says about the email address. Opened | |
| 94 | + | * from the invite's own email (`emailProven`), the address is confirmed | |
| 95 | + | * already, so there is no code to enter; otherwise the address is confirmed | |
| 96 | + | * after sign-up, as it always is. | |
| 97 | + | */ | |
| 98 | + | export function inviteSignUpCopy(invite: Proven): { | |
| 99 | + | /** Under "Create your account". */ | |
| 100 | + | intro: string; | |
| 101 | + | /** Under the email field. */ | |
| 102 | + | hint: string; | |
| 103 | + | /** Said plainly above the form when the address is confirmed already; null otherwise. */ | |
| 104 | + | confirmed: string | null; | |
| 105 | + | } { | |
| 106 | + | const proven = invite.emailProven && invite.address !== null; | |
| 107 | + | const when = proven ? "as soon as you create it" : "as soon as you confirm your email"; | |
| 108 | + | const intro = invite.workspace | |
| 109 | + | ? `You join ${invite.workspace.name} ${when}.` | |
| 110 | + | : invite.repository | |
| 111 | + | ? `You get ${invite.repository.name} ${when}.` | |
| 112 | + | : "It takes a minute."; | |
| 113 | + | if (proven) { | |
| 114 | + | return { | |
| 115 | + | intro, | |
| 116 | + | hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.", | |
| 117 | + | confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`, | |
| 118 | + | }; | |
| 119 | + | } | |
| 120 | + | return { | |
| 121 | + | intro, | |
| 122 | + | hint: invite.address | |
| 123 | + | ? "Your invite was sent here. We email it a code to confirm it before you start." | |
| 124 | + | : "We email it a code to confirm it before you start.", | |
| 125 | + | confirmed: null, | |
| 126 | + | }; | |
| 127 | + | } | |
| 128 | + | ||
| 68 | 129 | type Listed = { | |
| 69 | 130 | status: "pending" | "awaiting_confirmation" | "redeemed" | "expired" | "revoked"; | |
| 70 | 131 | redeemedBy: string | null; |
| 1 | − | import { CircleAlert, Lock, Ticket } from "lucide-react"; | |
| 1 | + | import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { InvitePreview, User } from "@g1t/contracts"; | |
| ⋯ | |||
| 11 | 11 | import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 12 | 12 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 14 | + | import { cleanCode, cleanProof, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 15 | 15 | import { clientKey } from "../lib/registration.server"; | |
| 16 | 16 | import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server"; | |
| 17 | 17 | import { rememberWorkspace } from "../lib/workspace-choice"; | |
| ⋯ | |||
| 29 | 29 | * an address that has an account), and the workspace or repository it | |
| 30 | 30 | * gives. Signing in or up elsewhere (GitHub, /login) comes back here with | |
| 31 | 31 | * `?accept=1`, which finishes the job. | |
| 32 | + | * | |
| 33 | + | * The link in the invite's own email also carries `?proof=`, which only | |
| 34 | + | * that email has: signing up from it makes the account with the address | |
| 35 | + | * confirmed already. The code alone (typed in, or a link passed on) does | |
| 36 | + | * not, and the address is confirmed after sign-up as usual. | |
| 32 | 37 | */ | |
| 33 | 38 | export async function loader({ request, context, params }: Route.LoaderArgs) { | |
| 34 | 39 | const code = cleanCode(params.code); | |
| 35 | 40 | const viewer = getViewer(context); | |
| 36 | − | const accepting = new URL(request.url).searchParams.get("accept") === "1"; | |
| 37 | − | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true }); | |
| 41 | + | const search = new URL(request.url).searchParams; | |
| 42 | + | const accepting = search.get("accept") === "1"; | |
| 43 | + | const emailProof = cleanProof(search.get("proof")); | |
| 44 | + | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof }); | |
| 38 | 45 | const invite = checked.ok ? checked.value : null; | |
| 39 | 46 | // A shared link for a group signs up on /register, which names the group. | |
| 40 | 47 | if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`); | |
| ⋯ | |||
| 61 | 68 | alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false, | |
| 62 | 69 | github: false, | |
| 63 | 70 | suggestion: suggestUsername(invite?.address), | |
| 71 | + | // Only a proof identity accepted goes back into the form. | |
| 72 | + | proof: invite?.emailProven ? emailProof : null, | |
| 64 | 73 | started: Date.now(), | |
| 65 | 74 | acceptError: null as string | null, | |
| 66 | 75 | }; | |
| ⋯ | |||
| 95 | 104 | const code = cleanCode(params.code); | |
| 96 | 105 | const form = await request.formData(); | |
| 97 | 106 | const client = clientKey(request); | |
| 98 | − | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) }); | |
| 107 | + | const emailProof = cleanProof(String(form.get("proof") ?? "")); | |
| 108 | + | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context), emailProof }); | |
| 99 | 109 | if (!checked.ok) return data({ error: checked.error.message }, { status: 422 }); | |
| 100 | 110 | const invite = checked.value; | |
| 101 | 111 | ||
| ⋯ | |||
| 110 | 120 | String(form.get("password") ?? ""), | |
| 111 | 121 | code, | |
| 112 | 122 | client, | |
| 123 | + | // Identity checks it again, against this invite and this address. | |
| 124 | + | emailProof, | |
| 113 | 125 | ); | |
| 114 | 126 | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); | |
| 115 | 127 | throw landIn(request, invite, [startSession(result.value.sessionToken)], true); | |
| ⋯ | |||
| 202 | 214 | const here = `/invite/${loaded.code}`; | |
| 203 | 215 | const back = `${here}?accept=1`; | |
| 204 | 216 | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; | |
| 217 | + | const copy = inviteSignUpCopy(invite); | |
| 205 | 218 | return ( | |
| 206 | 219 | <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6"> | |
| 207 | 220 | <h2 id="sign-up" className="text-base font-semibold"> | |
| 208 | 221 | Create your account | |
| 209 | 222 | </h2> | |
| 210 | − | <p className="mt-1 text-sm text-muted"> | |
| 211 | − | {invite.workspace | |
| 212 | − | ? `You join ${invite.workspace.name} as soon as you confirm your email.` | |
| 213 | − | : invite.repository | |
| 214 | − | ? `You get ${invite.repository.name} as soon as you confirm your email.` | |
| 215 | − | : "It takes a minute."} | |
| 216 | − | </p> | |
| 223 | + | <p className="mt-1 text-sm text-muted">{copy.intro}</p> | |
| 224 | + | {copy.confirmed && ( | |
| 225 | + | <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status"> | |
| 226 | + | <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" /> | |
| 227 | + | <span>{copy.confirmed}</span> | |
| 228 | + | </p> | |
| 229 | + | )} | |
| 217 | 230 | {loaded.github && ( | |
| 218 | 231 | <div className="mt-5"> | |
| 219 | 232 | <ContinueWithGithub href={github} /> | |
| ⋯ | |||
| 223 | 236 | <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}> | |
| 224 | 237 | <input type="hidden" name="intent" value="register" /> | |
| 225 | 238 | <Honeypot started={loaded.started} /> | |
| 239 | + | {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />} | |
| 226 | 240 | {invite.address ? ( | |
| 227 | − | <Field label="Email" hint="Your invite was sent here. We email it a code to confirm it before you start."> | |
| 241 | + | <Field label="Email" hint={copy.hint}> | |
| 228 | 242 | <span className="relative block"> | |
| 229 | 243 | <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" /> | |
| 230 | − | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 244 | + | {copy.confirmed ? ( | |
| 245 | + | <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" /> | |
| 246 | + | ) : ( | |
| 247 | + | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 248 | + | )} | |
| 231 | 249 | </span> | |
| 232 | 250 | </Field> | |
| 233 | 251 | ) : ( | |
| 234 | − | <Field label="Email" hint="We email it a code to confirm it before you start."> | |
| 252 | + | <Field label="Email" hint={copy.hint}> | |
| 235 | 253 | <Input name="email" type="email" autoComplete="email" required maxLength={254} /> | |
| 236 | 254 | </Field> | |
| 237 | 255 | )} | |
| 11 | 11 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 12 | 12 | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites"; | |
| 14 | + | import { cleanCode, cleanProof, invitePath, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites"; | |
| 15 | 15 | import { clientKey, registrationMode } from "../lib/registration.server"; | |
| 16 | 16 | import { | |
| 17 | 17 | assertSameOrigin, | |
| ⋯ | |||
| 36 | 36 | // where it leads; it signs up, joins and lands in one go. A shared link | |
| 37 | 37 | // for a group signs up here: it joins nothing, and the form says which | |
| 38 | 38 | // group it is for. | |
| 39 | − | if (invite && code && !invite.sharedLabel) throw redirect(`/invite/${encodeURIComponent(code)}`); | |
| 39 | + | // The invite email's proof goes with it, so the address it proves stays | |
| 40 | + | // confirmed there. | |
| 41 | + | if (invite && code && !invite.sharedLabel) { | |
| 42 | + | throw redirect(invitePath(code, cleanProof(new URL(request.url).searchParams.get("proof")))); | |
| 43 | + | } | |
| 40 | 44 | // Signing up with GitHub carries the invite code and `next` through it. | |
| 41 | 45 | const params = new URLSearchParams(); | |
| 42 | 46 | if (code) params.set("invite", code); | |
| 245 | 245 | /// registration is open. | |
| 246 | 246 | #[serde(default)] | |
| 247 | 247 | pub invite_code: Option<String>, | |
| 248 | + | /// The `proof` from the invite email's link. When it is the invite's | |
| 249 | + | /// own and `email` is the address the invite was sent to, the account | |
| 250 | + | /// starts with that address confirmed; otherwise it is ignored. | |
| 251 | + | #[serde(default)] | |
| 252 | + | pub email_proof: Option<String>, | |
| 248 | 253 | /// Who is asking, such as the visitor's IP address, for rate limits. | |
| 249 | 254 | #[serde(default)] | |
| 250 | 255 | pub client: Option<String>, | |
| ⋯ | |||
| 1278 | 1283 | pub viewer: Option<User>, | |
| 1279 | 1284 | #[serde(default)] | |
| 1280 | 1285 | pub any_status: bool, | |
| 1286 | + | /// The `proof` from the invite email's link, if the page was opened | |
| 1287 | + | /// from it: sets `InvitePreview::email_proven`. | |
| 1288 | + | #[serde(default)] | |
| 1289 | + | pub email_proof: Option<String>, | |
| 1281 | 1290 | } | |
| 1282 | 1291 | ||
| 1283 | 1292 | /// Someone shown on an invite. | |
| ⋯ | |||
| 1333 | 1342 | /// domains, such as `["cloudflare.com"]`. Empty for any address. | |
| 1334 | 1343 | #[serde(default)] | |
| 1335 | 1344 | pub shared_domains: Vec<String>, | |
| 1345 | + | /// Whether `email_proof` was this pending invite's own, from the email | |
| 1346 | + | /// it was sent in: the account made with it starts with `address` | |
| 1347 | + | /// confirmed. False without a proof, with a wrong one, and for an | |
| 1348 | + | /// invite bound to no address. | |
| 1349 | + | #[serde(default)] | |
| 1350 | + | pub email_proven: bool, | |
| 1336 | 1351 | } | |
| 1337 | 1352 | ||
| 1338 | 1353 | /// `accept_invite`: a signed-in person uses a workspace invite made for | |
| 1 | 1 | use serde::de::DeserializeOwned; | |
| 2 | + | use serde::de::value::UnitDeserializer; | |
| 2 | 3 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; | |
| 3 | 4 | ||
| 4 | 5 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| ⋯ | |||
| 114 | 115 | match (wire.ok, wire.value, wire.error) { | |
| 115 | 116 | (true, Some(value), _) => Ok(Outcome::Ok(value)), | |
| 116 | 117 | (false, _, Some(error)) => Ok(Outcome::Fail(error)), | |
| 118 | + | // `"value": null`, or no value: what `Ok(None)` and `Ok(())` are | |
| 119 | + | // written as. `Option<Option<T>>` reads a null as the outer | |
| 120 | + | // `None`, so the value is read from nothing instead: `None` for | |
| 121 | + | // an `Option`, `()` for a unit, and still malformed for a type | |
| 122 | + | // that needs a value. | |
| 123 | + | (true, None, _) => T::deserialize(UnitDeserializer::<D::Error>::new()) | |
| 124 | + | .map(Outcome::Ok) | |
| 125 | + | .map_err(|_| serde::de::Error::custom("malformed outcome: ok without a value")), | |
| 117 | 126 | _ => Err(serde::de::Error::custom("malformed outcome")), | |
| 118 | 127 | } | |
| 119 | 128 | } | |
| 120 | 129 | } | |
| 130 | + | ||
| 131 | + | #[cfg(test)] | |
| 132 | + | mod tests { | |
| 133 | + | use super::*; | |
| 134 | + | ||
| 135 | + | fn round_trip<T: Serialize + DeserializeOwned>(outcome: &Outcome<T>) -> Result<Outcome<T>, String> { | |
| 136 | + | // As a service replies (`g1t_kit::reply`) and its caller reads it | |
| 137 | + | // (`g1t_kit::call`). | |
| 138 | + | let wire = serde_json::to_string(outcome).map_err(|e| e.to_string())?; | |
| 139 | + | serde_json::from_str(&wire).map_err(|e| e.to_string()) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// A cache miss is `Ok(None)`, written `{"ok":true,"value":null}`. It | |
| 143 | + | /// was read back as malformed, so every miss of the toolkit's cache | |
| 144 | + | /// (sccache's first lookup, `sccache/.sccache_check`) was a 500. | |
| 145 | + | #[test] | |
| 146 | + | fn ok_none_and_ok_unit_cross_the_wire() { | |
| 147 | + | assert_eq!(serde_json::to_string(&Outcome::<Option<u8>>::Ok(None)).unwrap(), r#"{"ok":true,"value":null}"#); | |
| 148 | + | assert!(matches!(round_trip(&Outcome::<Option<u8>>::Ok(None)), Ok(Outcome::Ok(None)))); | |
| 149 | + | assert!(matches!(round_trip(&Outcome::Ok(Some(7u8))), Ok(Outcome::Ok(Some(7))))); | |
| 150 | + | assert!(matches!(round_trip(&Outcome::Ok(())), Ok(Outcome::Ok(())))); | |
| 151 | + | assert!(matches!(serde_json::from_str::<Outcome<Option<u8>>>(r#"{"ok":true}"#), Ok(Outcome::Ok(None)))); | |
| 152 | + | let failed = round_trip(&Outcome::<Option<u8>>::fail(FailureCode::Unauthenticated, "no")); | |
| 153 | + | assert!(matches!(failed, Ok(Outcome::Fail(Failure { code: FailureCode::Unauthenticated, .. })))); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn an_ok_without_the_value_it_needs_is_still_malformed() { | |
| 158 | + | assert!(serde_json::from_str::<Outcome<u8>>(r#"{"ok":true,"value":null}"#).is_err()); | |
| 159 | + | assert!(serde_json::from_str::<Outcome<String>>(r#"{"ok":true}"#).is_err()); | |
| 160 | + | assert!(serde_json::from_str::<Outcome<u8>>(r#"{"ok":false}"#).is_err()); | |
| 161 | + | } | |
| 162 | + | } | |
| 57 | 57 | response.text().await.unwrap_or_default() | |
| 58 | 58 | ))); | |
| 59 | 59 | } | |
| 60 | − | response.json().await | |
| 60 | + | // Say which call's answer did not read, and why: a bare serde error | |
| 61 | + | // ("JSON serialization error") is all a log would otherwise show. The | |
| 62 | + | // body is left out, as it may carry a signed link. | |
| 63 | + | let text = response.text().await?; | |
| 64 | + | read_answer(method, &text) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// A method's answer, read from its body. | |
| 68 | + | pub fn read_answer<R: DeserializeOwned>(method: &str, text: &str) -> Result<R> { | |
| 69 | + | serde_json::from_str(text).map_err(|error| { | |
| 70 | + | worker::Error::RustError(format!("{method} answered with what could not be read: {error}")) | |
| 71 | + | }) | |
| 61 | 72 | } | |
| 62 | 73 | ||
| 63 | 74 | pub mod d1; |
| 41 | 41 | export function identityClient(service: ServiceBinding): IdentityApi { | |
| 42 | 42 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 43 | 43 | return { | |
| 44 | − | register: (username, email, password, inviteCode, client) => | |
| 45 | − | call("register", { username, email, password, invite_code: inviteCode ?? null, client: client ?? null }), | |
| 44 | + | register: (username, email, password, inviteCode, client, emailProof) => | |
| 45 | + | call("register", { | |
| 46 | + | username, | |
| 47 | + | email, | |
| 48 | + | password, | |
| 49 | + | invite_code: inviteCode ?? null, | |
| 50 | + | email_proof: emailProof ?? null, | |
| 51 | + | client: client ?? null, | |
| 52 | + | }), | |
| 46 | 53 | signIn: (username, password, client) => call("sign_in", { username, password, client: client ?? null }), | |
| 47 | 54 | twoFactorSignIn: (challenge, code, client) => call("two_factor_sign_in", { challenge, code, client: client ?? null }), | |
| 48 | 55 | signOut: (sessionToken) => call("sign_out", { sessionToken }), | |
| ⋯ | |||
| 153 | 160 | client: client ?? null, | |
| 154 | 161 | viewer: options.viewer ?? null, | |
| 155 | 162 | any_status: options.anyStatus ?? false, | |
| 163 | + | email_proof: options.emailProof ?? null, | |
| 156 | 164 | }), | |
| 157 | 165 | acceptInvite: (user, code) => call("accept_invite", { user, code }), | |
| 158 | 166 | inviteMember: (actor, slug, email) => call("invite_member", { actor, slug, email }), | |
| 281 | 281 | sharedLabel: string | null; | |
| 282 | 282 | /** The email domains a shared invite link is limited to; empty for any address. */ | |
| 283 | 283 | sharedDomains: string[]; | |
| 284 | + | /** | |
| 285 | + | * Whether the page was opened from this pending invite's own email (its | |
| 286 | + | * `proof` checked out): the account made with it starts with `address` | |
| 287 | + | * confirmed. False without a proof, with a wrong one, or for an invite | |
| 288 | + | * bound to no address. | |
| 289 | + | */ | |
| 290 | + | emailProven: boolean; | |
| 284 | 291 | }; | |
| 285 | 292 | ||
| 286 | 293 | export type WaitlistStatus = "waiting" | "invited" | "dismissed"; | |
| ⋯ | |||
| 702 | 709 | inviteCode?: string | null, | |
| 703 | 710 | /** Who is asking, such as the visitor's IP address, for rate limits. */ | |
| 704 | 711 | client?: string | null, | |
| 712 | + | /** | |
| 713 | + | * The `proof` from the invite email's link. When it is the invite's own | |
| 714 | + | * and `email` is the address it was sent to, the account starts with that | |
| 715 | + | * address confirmed; otherwise it is ignored. | |
| 716 | + | */ | |
| 717 | + | emailProof?: string | null, | |
| 705 | 718 | ): Promise<Result<{ user: User; sessionToken: string }>>; | |
| 706 | 719 | /** Verifies a username and password for website sign-in. */ | |
| 707 | 720 | /** | |
| ⋯ | |||
| 890 | 903 | /** | |
| 891 | 904 | * What a code is for. Unknown, used, revoked and expired codes all get the | |
| 892 | 905 | * same answer, unless `anyStatus`: then a real code that is spent is | |
| 893 | − | * described, with its `status`. `viewer` sets `forViewer`. | |
| 906 | + | * described, with its `status`. `viewer` sets `forViewer`; `emailProof`, | |
| 907 | + | * the `proof` from the invite email's link, sets `emailProven`. | |
| 894 | 908 | */ | |
| 895 | 909 | checkInvite( | |
| 896 | 910 | code: string, | |
| 897 | 911 | client?: string | null, | |
| 898 | − | options?: { viewer?: User | null; anyStatus?: boolean }, | |
| 912 | + | options?: { viewer?: User | null; anyStatus?: boolean; emailProof?: string | null }, | |
| 899 | 913 | ): Promise<Result<InvitePreview>>; | |
| 900 | 914 | /** | |
| 901 | 915 | * A signed-in person uses a workspace invite sent to their address, or one | |
| 15 | 15 | ||
| 16 | 16 | /** The checks, in order. `body` is the page's text with tags removed. */ | |
| 17 | 17 | export const CHECKS = [ | |
| 18 | − | { name: "Landing page", path: "/", expect: ["Plan in issues"] }, | |
| 18 | + | { name: "Landing page", path: "/", expect: ["Sign up", "Start for free"] }, | |
| 19 | 19 | { name: "Sign in", path: "/login", expect: ["Sign in"] }, | |
| 20 | 20 | { | |
| 21 | 21 | name: "Sign up: invite and waitlist", |
| 759 | 759 | &full_name(&repo), | |
| 760 | 760 | a.role.label(), | |
| 761 | 761 | None, | |
| 762 | + | None, | |
| 762 | 763 | INVITATION_DAYS, | |
| 763 | 764 | ) | |
| 764 | 765 | .await | |
| ⋯ | |||
| 804 | 805 | let id = self | |
| 805 | 806 | .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days) | |
| 806 | 807 | .await?; | |
| 808 | + | // The email's link proves the address, as any invite email's does. | |
| 809 | + | let proof = self.email_proof_for(&invite.id, email); | |
| 807 | 810 | if let Some(code) = &invite.code | |
| 808 | 811 | && let Err(error) = crate::email::send_repo_invite( | |
| 809 | 812 | &self.env, | |
| ⋯ | |||
| 812 | 815 | &full_name(repo), | |
| 813 | 816 | role.label(), | |
| 814 | 817 | Some(code), | |
| 818 | + | proof.as_deref(), | |
| 815 | 819 | days, | |
| 816 | 820 | ) | |
| 817 | 821 | .await | |
| 46 | 46 | hex::encode(mac.finalize().into_bytes()) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | /// The proof an invite email's link carries that whoever follows it reads | |
| 50 | + | /// that inbox: an HMAC-SHA256 under `key` (IDENTITY_KEY) of the invite's id | |
| 51 | + | /// and the address it is bound to, trimmed and lowercased. Only the email | |
| 52 | + | /// has it: the inviter sees the code, never this, and nobody can make one | |
| 53 | + | /// without the key. | |
| 54 | + | pub fn invite_proof(key: &[u8], invite_id: &str, email: &str) -> String { | |
| 55 | + | use hmac::{Hmac, Mac}; | |
| 56 | + | let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length"); | |
| 57 | + | mac.update(b"g1t invite email proof\0"); | |
| 58 | + | mac.update(invite_id.as_bytes()); | |
| 59 | + | mac.update(b"\0"); | |
| 60 | + | mac.update(email.trim().to_lowercase().as_bytes()); | |
| 61 | + | hex::encode(mac.finalize().into_bytes()) | |
| 62 | + | } | |
| 63 | + | ||
| 49 | 64 | /// Whether two strings are equal, in time that depends on their length only. | |
| 50 | 65 | pub fn same(a: &str, b: &str) -> bool { | |
| 51 | 66 | a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0 |
| 288 | 288 | pub workspace: Option<&'a str>, | |
| 289 | 289 | pub joins_existing_account: bool, | |
| 290 | 290 | pub code: &'a str, | |
| 291 | + | /// The proof that whoever follows the link reads this inbox | |
| 292 | + | /// (invites.rs, `email_proof`): the account made from it starts with | |
| 293 | + | /// the address confirmed. None for an invite to an existing account, | |
| 294 | + | /// or without IDENTITY_KEY. | |
| 295 | + | pub proof: Option<&'a str>, | |
| 291 | 296 | pub days: u64, | |
| 292 | 297 | /// A line from whoever sent it, such as staff approving a request. | |
| 293 | 298 | pub note: Option<&'a str>, | |
| 294 | 299 | } | |
| 295 | 300 | ||
| 301 | + | /// An invite's page, as its email links to it: with the email's proof | |
| 302 | + | /// when it has one, so following it confirms the address (invites.rs). | |
| 303 | + | /// The code alone is what the inviter can see and share. | |
| 304 | + | pub fn invite_link(site: &str, code: &str, proof: Option<&str>) -> String { | |
| 305 | + | match proof { | |
| 306 | + | Some(proof) => format!("{site}/invite/{code}?proof={proof}"), | |
| 307 | + | None => format!("{site}/invite/{code}"), | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 296 | 311 | /// The subject and letter of an invite email. | |
| 297 | 312 | pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) { | |
| 298 | 313 | let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account); | |
| ⋯ | |||
| 314 | 329 | paragraphs: vec![intro], | |
| 315 | 330 | quotes, | |
| 316 | 331 | code: None, | |
| 317 | − | action: Some((action, format!("{site}/invite/{}", invite.code))), | |
| 332 | + | action: Some((action, invite_link(site, invite.code, invite.proof))), | |
| 318 | 333 | footer: format!( | |
| 319 | 334 | "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.", | |
| 320 | 335 | invite.days | |
| ⋯ | |||
| 413 | 428 | } | |
| 414 | 429 | ||
| 415 | 430 | /// An invitation to collaborate on one repository. `code` is set when the | |
| 416 | − | /// address has no account yet: the link then makes one and accepts; without | |
| 417 | − | /// it, the link opens the invitation to accept or decline. | |
| 431 | + | /// address has no account yet: the link then makes one and accepts, with | |
| 432 | + | /// `proof` (see [`invite_link`]); without it, the link opens the | |
| 433 | + | /// invitation to accept or decline. | |
| 418 | 434 | pub async fn send_repo_invite( | |
| 419 | 435 | env: &Env, | |
| 420 | 436 | to: &str, | |
| ⋯ | |||
| 422 | 438 | repo: &str, | |
| 423 | 439 | role: &str, | |
| 424 | 440 | code: Option<&str>, | |
| 441 | + | proof: Option<&str>, | |
| 425 | 442 | days: u64, | |
| 426 | 443 | ) -> Result<()> { | |
| 427 | 444 | let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some()); | |
| 428 | 445 | let link = match code { | |
| 429 | − | Some(code) => format!("{}/invite/{code}", site(env)), | |
| 446 | + | Some(code) => invite_link(&site(env), code, proof), | |
| 430 | 447 | None => format!("{}/{repo}/invitations", site(env)), | |
| 431 | 448 | }; | |
| 432 | 449 | send_link( | |
| ⋯ | |||
| 574 | 591 | workspace: Some("Flagon, Inc."), | |
| 575 | 592 | joins_existing_account: false, | |
| 576 | 593 | code: "g1t-abcd", | |
| 594 | + | proof: None, | |
| 577 | 595 | days: 30, | |
| 578 | 596 | note, | |
| 579 | 597 | } | |
| ⋯ | |||
| 596 | 614 | } | |
| 597 | 615 | ||
| 598 | 616 | #[test] | |
| 617 | + | fn an_invite_link_carries_the_emails_proof_when_it_has_one() { | |
| 618 | + | let proven = InviteEmail { proof: Some("4f9c2a"), ..invite(None, None) }; | |
| 619 | + | let (_, letter) = invite_letter(&proven, SITE); | |
| 620 | + | assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd?proof=4f9c2a"); | |
| 621 | + | // An invite sent before proofs, or for an existing account: the code alone. | |
| 622 | + | assert_eq!(invite_link(SITE, "g1t-abcd", None), "https://g1t.sh/invite/g1t-abcd"); | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | #[test] | |
| 599 | 626 | fn links_point_at_the_site_they_are_given() { | |
| 600 | 627 | let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787"); | |
| 601 | 628 | assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd"); | |
| ⋯ | |||
| 688 | 715 | workspace: Some("Flagon, Inc."), | |
| 689 | 716 | joins_existing_account: false, | |
| 690 | 717 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 718 | + | proof: None, | |
| 691 | 719 | days: 30, | |
| 692 | 720 | note: None, | |
| 693 | 721 | }, SITE); | |
| ⋯ | |||
| 698 | 726 | workspace: None, | |
| 699 | 727 | joins_existing_account: false, | |
| 700 | 728 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 729 | + | proof: None, | |
| 701 | 730 | days: 30, | |
| 702 | 731 | note: Some("Thanks for waiting. We would love to see the compiler."), | |
| 703 | 732 | }, SITE); | |
| 584 | 584 | password_hash: "", | |
| 585 | 585 | verified: true, | |
| 586 | 586 | invite_code, | |
| 587 | + | // GitHub has confirmed the address already. | |
| 588 | + | email_proof: None, | |
| 587 | 589 | client: None, | |
| 588 | 590 | }) | |
| 589 | 591 | .await? |
| 233 | 233 | } | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | + | /// The proof for an invite's email link, or None when there is none to | |
| 237 | + | /// make: no key (a development setup), or no address the invite is bound | |
| 238 | + | /// to. See [`crypto::invite_proof`]. | |
| 239 | + | pub fn email_proof(key: &[u8], invite_id: &str, bound: Option<&str>) -> Option<String> { | |
| 240 | + | let bound = bound.map(str::trim).filter(|bound| !bound.is_empty())?; | |
| 241 | + | (!key.is_empty()).then(|| crypto::invite_proof(key, invite_id, bound)) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// Whether `proof` shows that whoever brings it followed the invite's own | |
| 245 | + | /// email: it is the proof for this invite and the address it is bound to, | |
| 246 | + | /// and `email`, the address the account is made with, is that address. | |
| 247 | + | /// Anything else (no proof, a wrong or altered one, another invite's, an | |
| 248 | + | /// invite bound to no address, a different address) proves nothing, and | |
| 249 | + | /// the address is confirmed as any other is. | |
| 250 | + | pub fn proves_email(key: &[u8], invite_id: &str, bound: Option<&str>, email: &str, proof: Option<&str>) -> bool { | |
| 251 | + | let (Some(expected), Some(proof)) = (email_proof(key, invite_id, bound), proof.map(str::trim)) else { | |
| 252 | + | return false; | |
| 253 | + | }; | |
| 254 | + | let same_address = bound.is_some_and(|bound| bound.trim().to_lowercase() == email.trim().to_lowercase()); | |
| 255 | + | same_address && crypto::same(&expected, &proof.to_ascii_lowercase()) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// Whether a new account starts with its address confirmed: GitHub | |
| 259 | + | /// confirmed it (`verified`), or `invite`, the one-person invite that | |
| 260 | + | /// admitted it, was followed from its own email with `proof` and `email` is | |
| 261 | + | /// the address it was sent to. A shared link, a code typed in or passed on, | |
| 262 | + | /// or an invite bound to no address: confirmed as any other is. | |
| 263 | + | pub fn starts_confirmed(key: &[u8], verified: bool, invite: Option<&InviteRow>, email: &str, proof: Option<&str>) -> bool { | |
| 264 | + | verified | |
| 265 | + | || invite.is_some_and(|row| row.kind == "account" && proves_email(key, &row.id, row.email.as_deref(), email, proof)) | |
| 266 | + | } | |
| 267 | + | ||
| 236 | 268 | /// What an invite used to sign up does once its account confirms its | |
| 237 | 269 | /// address. | |
| 238 | 270 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| ⋯ | |||
| 439 | 471 | /// Whether the address is confirmed already (GitHub's verified email). | |
| 440 | 472 | pub verified: bool, | |
| 441 | 473 | pub invite_code: Option<&'a str>, | |
| 474 | + | /// The proof from the invite email's link ([`proves_email`]): when it | |
| 475 | + | /// is the invite's and `email` is the address the invite was sent to, | |
| 476 | + | /// the account starts with that address confirmed. | |
| 477 | + | pub email_proof: Option<&'a str>, | |
| 442 | 478 | /// Who is asking, for rate limits. | |
| 443 | 479 | pub client: Option<&'a str>, | |
| 444 | 480 | } | |
| ⋯ | |||
| 498 | 534 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) | |
| 499 | 535 | } | |
| 500 | 536 | ||
| 537 | + | /// The key invite email proofs are made under: IDENTITY_KEY, or none | |
| 538 | + | /// in a development setup without one (then no proof is made, and none | |
| 539 | + | /// is accepted). | |
| 540 | + | fn proof_key(&self) -> Vec<u8> { | |
| 541 | + | self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default() | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | /// The proof for the link of an invite emailed to `to`, the address it | |
| 545 | + | /// is bound to; never shown anywhere but in that email. | |
| 546 | + | pub(crate) fn email_proof_for(&self, invite_id: &str, to: &str) -> Option<String> { | |
| 547 | + | email_proof(&self.proof_key(), invite_id, Some(to)) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | /// Whether `proof` shows the invite in `row` was followed from its own | |
| 551 | + | /// email, by someone making an account with `email`. | |
| 552 | + | fn proven(&self, row: &InviteRow, email: &str, proof: Option<&str>) -> bool { | |
| 553 | + | starts_confirmed(&self.proof_key(), false, Some(row), email, proof) | |
| 554 | + | } | |
| 555 | + | ||
| 501 | 556 | // --- Rate limits --- | |
| 502 | 557 | ||
| 503 | 558 | /// Counts one more hit on `key` this hour; false once past `limit`. | |
| ⋯ | |||
| 693 | 748 | /// invite-only, `invite_code` must admit `email`; the code is spent in | |
| 694 | 749 | /// the same transaction as the account is made. What the invite gives | |
| 695 | 750 | /// (a workspace, repository invitations) is applied once the address | |
| 696 | − | /// is confirmed: at once for an address GitHub has confirmed, otherwise | |
| 751 | + | /// is confirmed: at once for an address GitHub has confirmed or one | |
| 752 | + | /// proven by the invite email's link ([`proves_email`]), otherwise | |
| 697 | 753 | /// in the transaction that confirms it (emails.rs, `confirm_address`). | |
| 698 | 754 | /// In open mode a code is used if it is good and otherwise ignored. | |
| 699 | 755 | pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> { | |
| ⋯ | |||
| 739 | 795 | } | |
| 740 | 796 | } | |
| 741 | 797 | ||
| 742 | − | // Only an address GitHub has confirmed starts confirmed. An invite | |
| 743 | − | // bound to the address proves nothing: its link can be forwarded, | |
| 744 | − | // so the new account confirms the address like any other. | |
| 745 | − | let verified = new.verified; | |
| 798 | + | // An address GitHub has confirmed starts confirmed, and so does the | |
| 799 | + | // address an invite was emailed to, when the link followed was the | |
| 800 | + | // email's own: its proof is in no code the inviter sees or shares. | |
| 801 | + | // The code alone proves nothing (it can be passed on), so without | |
| 802 | + | // the proof the new account confirms the address like any other. | |
| 803 | + | let verified = starts_confirmed(&self.proof_key(), new.verified, invite.as_ref(), new.email, new.email_proof); | |
| 746 | 804 | let user = User { | |
| 747 | 805 | id: new_id("usr", now_ms()), | |
| 748 | 806 | username: new.username.to_owned(), | |
| ⋯ | |||
| 823 | 881 | self.count_failure(new.client).await?; | |
| 824 | 882 | return Ok(Outcome::fail(FailureCode::Forbidden, INVALID)); | |
| 825 | 883 | } | |
| 826 | − | // Confirmed already (GitHub): what the invite gives, now. Otherwise | |
| 884 | + | // Confirmed already (GitHub, or the invite email): what the invite gives, now. Otherwise | |
| 827 | 885 | // it waits, spent, for the address to be confirmed. | |
| 828 | 886 | if let Some(row) = invite | |
| 829 | 887 | && user.verified | |
| ⋯ | |||
| 1169 | 1227 | }; | |
| 1170 | 1228 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 1171 | 1229 | let from = self.display_name(&a.user).await; | |
| 1172 | − | self.send_invite_email(email, Some(&from), None, false, code, None).await; | |
| 1230 | + | self.send_invite_email(email, Some(&from), None, false, code, &invite.id, None).await; | |
| 1173 | 1231 | } | |
| 1174 | 1232 | let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(); | |
| 1175 | 1233 | self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint)) | |
| ⋯ | |||
| 1184 | 1242 | workspace: Option<&str>, | |
| 1185 | 1243 | existing: bool, | |
| 1186 | 1244 | code: &str, | |
| 1245 | + | invite_id: &str, | |
| 1187 | 1246 | note: Option<&str>, | |
| 1188 | 1247 | ) { | |
| 1248 | + | // An invite that makes an account carries the proof that the link | |
| 1249 | + | // came from this email; one for an existing account has nothing | |
| 1250 | + | // to prove. | |
| 1251 | + | let proof = if existing { None } else { self.email_proof_for(invite_id, to) }; | |
| 1189 | 1252 | let invite = crate::email::InviteEmail { | |
| 1190 | 1253 | to, | |
| 1191 | 1254 | from, | |
| 1192 | 1255 | workspace, | |
| 1193 | 1256 | joins_existing_account: existing, | |
| 1194 | 1257 | code, | |
| 1258 | + | proof: proof.as_deref(), | |
| 1195 | 1259 | days: self.invite_ttl_days(), | |
| 1196 | 1260 | note, | |
| 1197 | 1261 | }; | |
| ⋯ | |||
| 1349 | 1413 | _ => false, | |
| 1350 | 1414 | }; | |
| 1351 | 1415 | let repository = self.repository_of_code(&row.id).await?; | |
| 1416 | + | // Opened from the invite's own email: the account it makes starts | |
| 1417 | + | // with the address confirmed. Said only while it can make one. | |
| 1418 | + | let email_proven = pending | |
| 1419 | + | && !has_account | |
| 1420 | + | && row.email.as_deref().is_some_and(|bound| self.proven(&row, bound, a.email_proof.as_deref())); | |
| 1352 | 1421 | #[derive(Deserialize)] | |
| 1353 | 1422 | struct From { | |
| 1354 | 1423 | username: String, | |
| ⋯ | |||
| 1391 | 1460 | expires_at: row.expires_at, | |
| 1392 | 1461 | shared_label: None, | |
| 1393 | 1462 | shared_domains: Vec::new(), | |
| 1463 | + | email_proven, | |
| 1394 | 1464 | })) | |
| 1395 | 1465 | } | |
| 1396 | 1466 | ||
| ⋯ | |||
| 1550 | 1620 | if let Some(code) = &invite.code { | |
| 1551 | 1621 | let from = self.display_name(&a.actor).await; | |
| 1552 | 1622 | let workspace = self.workspace_name(&workspace_id, &slug).await; | |
| 1553 | − | self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, None).await; | |
| 1623 | + | self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, None).await; | |
| 1554 | 1624 | } | |
| 1555 | 1625 | self.audit_invites( | |
| 1556 | 1626 | &a.actor, | |
| ⋯ | |||
| 1953 | 2023 | return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again.")); | |
| 1954 | 2024 | }; | |
| 1955 | 2025 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 1956 | − | self.send_invite_email(email, None, None, false, code, note).await; | |
| 2026 | + | self.send_invite_email(email, None, None, false, code, &invite.id, note).await; | |
| 1957 | 2027 | } | |
| 1958 | 2028 | invite.staff = Some(staff.to_owned()); | |
| 1959 | 2029 | Ok(Outcome::Ok(invite)) | |
| ⋯ | |||
| 2396 | 2466 | assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(())); | |
| 2397 | 2467 | } | |
| 2398 | 2468 | ||
| 2469 | + | const KEY: &[u8] = b"identity key"; | |
| 2470 | + | ||
| 2471 | + | #[test] | |
| 2472 | + | fn an_invite_emails_proof_is_for_its_invite_and_address_only() { | |
| 2473 | + | let proof = email_proof(KEY, "inv_1", Some("ada@example.com")).unwrap(); | |
| 2474 | + | assert_eq!(proof.len(), 64); | |
| 2475 | + | let proves = |id: &str, bound: Option<&str>, email: &str, proof: Option<&str>| proves_email(KEY, id, bound, email, proof); | |
| 2476 | + | // The right invite and address, however the address is written. | |
| 2477 | + | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof))); | |
| 2478 | + | assert!(proves("inv_1", Some("Ada@Example.com"), " ADA@example.com ", Some(&proof))); | |
| 2479 | + | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof.to_uppercase()))); | |
| 2480 | + | // Another address: the account confirms that one itself. | |
| 2481 | + | assert!(!proves("inv_1", Some("ada@example.com"), "eve@example.com", Some(&proof))); | |
| 2482 | + | // Another invite's proof, even for the same address. | |
| 2483 | + | assert!(!proves("inv_2", Some("ada@example.com"), "ada@example.com", Some(&proof))); | |
| 2484 | + | // Tampered, cut short, empty or missing. | |
| 2485 | + | let mut tampered = proof.clone().into_bytes(); | |
| 2486 | + | tampered[10] = if tampered[10] == b'0' { b'1' } else { b'0' }; | |
| 2487 | + | let tampered = String::from_utf8(tampered).unwrap(); | |
| 2488 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&tampered))); | |
| 2489 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof[..32]))); | |
| 2490 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(""))); | |
| 2491 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", None)); | |
| 2492 | + | // An invite bound to no address has no proof to give. | |
| 2493 | + | assert_eq!(email_proof(KEY, "inv_1", None), None); | |
| 2494 | + | assert!(!proves("inv_1", None, "ada@example.com", Some(&proof))); | |
| 2495 | + | // Made under another key: not ours. | |
| 2496 | + | let foreign = email_proof(b"another key", "inv_1", Some("ada@example.com")).unwrap(); | |
| 2497 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&foreign))); | |
| 2498 | + | // Without a key (development) none is made, and none is taken. | |
| 2499 | + | assert_eq!(email_proof(b"", "inv_1", Some("ada@example.com")), None); | |
| 2500 | + | let unkeyed = crypto::invite_proof(b"", "inv_1", "ada@example.com"); | |
| 2501 | + | assert!(!proves_email(b"", "inv_1", Some("ada@example.com"), "ada@example.com", Some(&unkeyed))); | |
| 2502 | + | } | |
| 2503 | + | ||
| 2504 | + | #[test] | |
| 2505 | + | fn an_account_starts_confirmed_only_from_the_invite_email_to_its_address() { | |
| 2506 | + | let invite = row(None, false, LATER); | |
| 2507 | + | let proof = email_proof(KEY, &invite.id, invite.email.as_deref()).unwrap(); | |
| 2508 | + | // From the invite email, with the address it was sent to. | |
| 2509 | + | assert!(starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some(&proof))); | |
| 2510 | + | // The code alone (typed in, or a link passed on), or a bad proof. | |
| 2511 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", None)); | |
| 2512 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some("0123"))); | |
| 2513 | + | // A different address than the invite's. | |
| 2514 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "eve@example.com", Some(&proof))); | |
| 2515 | + | // No invite (open registration, or a shared link), or one bound to no address. | |
| 2516 | + | assert!(!starts_confirmed(KEY, false, None, "ada@example.com", Some(&proof))); | |
| 2517 | + | let unbound = InviteRow { email: None, ..row(None, false, LATER) }; | |
| 2518 | + | assert!(!starts_confirmed(KEY, false, Some(&unbound), "ada@example.com", Some(&proof))); | |
| 2519 | + | // A workspace invite makes no account. | |
| 2520 | + | let join = InviteRow { kind: "workspace".into(), ..row(None, false, LATER) }; | |
| 2521 | + | assert!(!starts_confirmed(KEY, false, Some(&join), "ada@example.com", Some(&proof))); | |
| 2522 | + | // GitHub's confirmed address, whatever else. | |
| 2523 | + | assert!(starts_confirmed(KEY, true, None, "ada@example.com", None)); | |
| 2524 | + | } | |
| 2525 | + | ||
| 2399 | 2526 | #[test] | |
| 2400 | 2527 | fn addresses_are_checked_and_masked() { | |
| 2401 | 2528 | assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com")); | |
| 427 | 427 | password_hash: &password_hash, | |
| 428 | 428 | verified: false, | |
| 429 | 429 | invite_code, | |
| 430 | + | email_proof: a.email_proof.as_deref(), | |
| 430 | 431 | client: a.client.as_deref(), | |
| 431 | 432 | }) | |
| 432 | 433 | .await? |
| 379 | 379 | expires_at: link.expires_at.clone(), | |
| 380 | 380 | shared_label: Some(link.label.clone()), | |
| 381 | 381 | shared_domains: link.domains(), | |
| 382 | + | email_proven: false, | |
| 382 | 383 | } | |
| 383 | 384 | } | |
| 384 | 385 |