| 1 | /** |
| 2 | * Invites, as the site shows them: links to an invite, and how each |
| 3 | * invitation reads in a list. |
| 4 | * No Workers or React imports, so it can be tested under Node. |
| 5 | */ |
| 6 | |
| 7 | /** What the sign-up buttons say: Sign up. Anyone may register. */ |
| 8 | export function signUpCopy(): { primary: string; secondary: string | null } { |
| 9 | return { primary: "Sign up", secondary: null }; |
| 10 | } |
| 11 | |
| 12 | /** The address an invite link has. */ |
| 13 | export function inviteLink(code: string, origin = "https://g1t.sh"): string { |
| 14 | return `${origin.replace(/\/+$/, "")}/invite/${code}`; |
| 15 | } |
| 16 | |
| 17 | /** |
| 18 | * An invite code from however someone pasted it: the code, a whole |
| 19 | * invite link, or a /register?invite= address. Identity reads it again; |
| 20 | * this only tidies what is put back into a form. |
| 21 | */ |
| 22 | export function cleanCode(raw: string | null | undefined): string { |
| 23 | let text = (raw ?? "").trim(); |
| 24 | const param = /[?&]invite=([^&#\s]+)/i.exec(text); |
| 25 | if (param) text = decodeURIComponent(param[1]!); |
| 26 | else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? ""; |
| 27 | return text.replace(/\s+/g, "").slice(0, 80); |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * The `proof` an invite email's link carries, tidied: hex, or null for |
| 32 | * anything else. Identity decides whether it is the invite's own; this only |
| 33 | * keeps junk out of what is passed on and put back into a form. |
| 34 | */ |
| 35 | export function cleanProof(raw: string | null | undefined): string | null { |
| 36 | const text = (raw ?? "").trim().toLowerCase(); |
| 37 | return /^[0-9a-f]{16,128}$/.test(text) ? text : null; |
| 38 | } |
| 39 | |
| 40 | /** An invite's page, keeping the email's proof when there is one. */ |
| 41 | export function invitePath(code: string, proof?: string | null): string { |
| 42 | const path = `/invite/${encodeURIComponent(code)}`; |
| 43 | return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path; |
| 44 | } |
| 45 | |
| 46 | type Proven = { |
| 47 | /** The bound address in full, or null for an invite to anyone with the code. */ |
| 48 | address: string | null; |
| 49 | emailProven: boolean; |
| 50 | workspace: { name: string } | null; |
| 51 | repository: { name: string } | null; |
| 52 | }; |
| 53 | |
| 54 | /** |
| 55 | * What signing up on an invite's page says about the email address. Opened |
| 56 | * from the invite's own email (`emailProven`), the address is confirmed |
| 57 | * already, so there is no code to enter; otherwise the address is confirmed |
| 58 | * after sign-up, as it always is. |
| 59 | */ |
| 60 | export function inviteSignUpCopy(invite: Proven): { |
| 61 | /** Under "Create your account". */ |
| 62 | intro: string; |
| 63 | /** Under the email field. */ |
| 64 | hint: string; |
| 65 | /** Said plainly above the form when the address is confirmed already; null otherwise. */ |
| 66 | confirmed: string | null; |
| 67 | } { |
| 68 | const proven = invite.emailProven && invite.address !== null; |
| 69 | const when = proven ? "as soon as you create it" : "as soon as you confirm your email"; |
| 70 | // A workspace is never joined without saying yes: the new account accepts its invitation. |
| 71 | const intro = invite.workspace |
| 72 | ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.` |
| 73 | : invite.repository |
| 74 | ? `You get ${invite.repository.name} ${when}.` |
| 75 | : "It takes a minute."; |
| 76 | if (proven) { |
| 77 | return { |
| 78 | intro, |
| 79 | hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.", |
| 80 | 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.`, |
| 81 | }; |
| 82 | } |
| 83 | return { |
| 84 | intro, |
| 85 | hint: invite.address |
| 86 | ? "Your invite was sent here. We email it a code to confirm it before you start." |
| 87 | : "We email it a code to confirm it before you start.", |
| 88 | confirmed: null, |
| 89 | }; |
| 90 | } |
| 91 | |
| 92 | type Listed = { |
| 93 | status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked"; |
| 94 | redeemedBy: string | null; |
| 95 | email: string | null; |
| 96 | workspace: string | null; |
| 97 | /** The account a workspace invitation is for, by username. */ |
| 98 | invitee?: string | null; |
| 99 | }; |
| 100 | |
| 101 | /** How an invite's state reads in a list. */ |
| 102 | export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } { |
| 103 | switch (invite.status) { |
| 104 | case "pending": |
| 105 | return { label: "Pending", tone: "pending" }; |
| 106 | case "awaiting_confirmation": |
| 107 | // The account is made; it joins once it confirms its address. |
| 108 | return { |
| 109 | label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email", |
| 110 | tone: "pending", |
| 111 | }; |
| 112 | case "awaiting_answer": { |
| 113 | // The account is made and confirmed; the workspace waits for its yes or no. |
| 114 | const who = invite.redeemedBy ?? invite.invitee; |
| 115 | return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" }; |
| 116 | } |
| 117 | case "redeemed": |
| 118 | return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" }; |
| 119 | case "declined": |
| 120 | return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" }; |
| 121 | case "expired": |
| 122 | return { label: "Expired", tone: "dead" }; |
| 123 | case "revoked": |
| 124 | return { label: "Revoked", tone: "dead" }; |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | type Previewed = { |
| 129 | kind: "account" | "workspace"; |
| 130 | invitedBy: { username: string } | null; |
| 131 | workspace: { name: string } | null; |
| 132 | repository: { name: string; role: string } | null; |
| 133 | hasAccount: boolean; |
| 134 | }; |
| 135 | |
| 136 | /** |
| 137 | * What an invite's page (/invite/:code) says it is, so nobody mistakes one |
| 138 | * kind for the other: the headline, in three parts with the place between |
| 139 | * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is |
| 140 | * an account and no workspace; "@syntaqx invited you to join Flagon, Inc. |
| 141 | * on g1t" is a workspace invitation, which also makes the account of |
| 142 | * someone who has none. |
| 143 | */ |
| 144 | export function invitePageCopy( |
| 145 | invite: Previewed, |
| 146 | signedIn: boolean, |
| 147 | ): { before: string; place: string | null; after: string; about: string } { |
| 148 | const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team"; |
| 149 | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; |
| 150 | const g1t = "g1t is where a team and the coding agents it already uses ship code"; |
| 151 | if (invite.workspace) { |
| 152 | const name = invite.workspace.name; |
| 153 | return { |
| 154 | before: `${from} invited you to join `, |
| 155 | place: name, |
| 156 | after: " on g1t", |
| 157 | about: `This is an invitation to join ${name}, which you accept or decline. ${ |
| 158 | signingUp |
| 159 | ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.` |
| 160 | : `Accepting joins you to ${name}.` |
| 161 | }`, |
| 162 | }; |
| 163 | } |
| 164 | if (invite.repository) { |
| 165 | return { |
| 166 | before: `${from} invited you to collaborate on `, |
| 167 | place: invite.repository.name, |
| 168 | after: "", |
| 169 | about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`, |
| 170 | }; |
| 171 | } |
| 172 | return { |
| 173 | before: `${from} invited you to g1t`, |
| 174 | place: null, |
| 175 | after: "", |
| 176 | about: `${g1t}: repositories, pull requests and checks that hold, a merge queue and deployments, with chat for the team. Sign up from this invite and your address is filled in for you. It does not add you to anyone's workspace: your account starts with a workspace of its own.`, |
| 177 | }; |
| 178 | } |
| 179 | |
| 180 | /** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */ |
| 181 | export type InviteResult = { invited: string } | { error: string }; |
| 182 | |
| 183 | /** What revoking or resending an invitation came to. */ |
| 184 | export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string }; |
| 185 | |
| 186 | /** |
| 187 | * The Invitations page's filters: every invitation, or the ones in one |
| 188 | * state. Pending gathers the three states an invitation waits in (to be |
| 189 | * used, for the new account to confirm its address, for the person's |
| 190 | * answer); Accepted is the ones that joined. g1t does not record an email |
| 191 | * that could not be delivered, so an invitation nobody used shows as |
| 192 | * Expired once its 30 days are up; there is no Failed filter until it does. |
| 193 | */ |
| 194 | export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked"; |
| 195 | |
| 196 | export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [ |
| 197 | { key: "all", label: "All" }, |
| 198 | { key: "pending", label: "Pending" }, |
| 199 | { key: "accepted", label: "Accepted" }, |
| 200 | { key: "declined", label: "Declined" }, |
| 201 | { key: "expired", label: "Expired" }, |
| 202 | { key: "revoked", label: "Revoked" }, |
| 203 | ]; |
| 204 | |
| 205 | /** The filter an invitation's state falls under. */ |
| 206 | export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> { |
| 207 | switch (status) { |
| 208 | case "pending": |
| 209 | case "awaiting_confirmation": |
| 210 | case "awaiting_answer": |
| 211 | return "pending"; |
| 212 | case "redeemed": |
| 213 | return "accepted"; |
| 214 | case "declined": |
| 215 | return "declined"; |
| 216 | case "expired": |
| 217 | return "expired"; |
| 218 | case "revoked": |
| 219 | return "revoked"; |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /** The filter `?state=` names; All for anything else. */ |
| 224 | export function invitationFilter(raw: string | null | undefined): InvitationFilter { |
| 225 | return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all"; |
| 226 | } |
| 227 | |
| 228 | /** The invitations under `filter`. */ |
| 229 | export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] { |
| 230 | if (filter === "all") return [...invites]; |
| 231 | return invites.filter((invite) => invitationFilterOf(invite.status) === filter); |
| 232 | } |
| 233 | |
| 234 | /** How many invitations each filter holds, for the counts on the chips. */ |
| 235 | export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> { |
| 236 | const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 }; |
| 237 | for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1; |
| 238 | return counts; |
| 239 | } |
| 240 | |
| 241 | /** The tone of an invitation's status badge, from how its state reads. */ |
| 242 | export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" { |
| 243 | switch (invitationFilterOf(status)) { |
| 244 | case "pending": |
| 245 | return "warn"; |
| 246 | case "accepted": |
| 247 | return "success"; |
| 248 | case "declined": |
| 249 | case "revoked": |
| 250 | return "danger"; |
| 251 | default: |
| 252 | return "neutral"; |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | /** Who an invite is for, in a list. */ |
| 257 | export function inviteFor(invite: Listed): string { |
| 258 | return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link"); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Which of the two invites a listed one is, in words: an invite to g1t |
| 263 | * (an account, and no workspace), or an invitation to join a workspace. |
| 264 | */ |
| 265 | export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } { |
| 266 | return invite.workspace |
| 267 | ? { kind: "workspace", label: `Invite to join ${invite.workspace}` } |
| 268 | : { kind: "g1t", label: "Invite to g1t" }; |
| 269 | } |
| 270 | |
| 271 | // --- Invite a friend ---------------------------------------------------------- |
| 272 | // |
| 273 | // Settings → Invites to g1t (`/settings/invites`): any person invites a friend |
| 274 | // to g1t by email. Anyone can sign up without one; the invite is a note from |
| 275 | // someone they know, with a link that opens sign-up with their address filled |
| 276 | // in and confirmed. Twenty each, for good: a revoked or expired one that was |
| 277 | // never used comes back. |
| 278 | |
| 279 | /** The words for Invite a friend. Never says sign-up is held or needs an invite. */ |
| 280 | export const G1T_INVITES = { |
| 281 | /** Its name in the settings menu. */ |
| 282 | nav: "Invites to g1t", |
| 283 | /** The page's heading. */ |
| 284 | heading: "Invite friends to g1t", |
| 285 | about: |
| 286 | "Anyone can sign up for g1t. An invite is a note from you: your friend gets an email with a link that opens sign-up with their address filled in and confirmed, and you see when they join.", |
| 287 | /** The account menu's entry and the page's button. */ |
| 288 | action: "Invite a friend", |
| 289 | /** The dialog's description. */ |
| 290 | dialog: |
| 291 | "They get an email from you with a link to sign up. Their account starts with a workspace of its own, and the invite works for 30 days.", |
| 292 | /** Under the count. */ |
| 293 | comesBack: "A revoked invite that was never used comes back to you.", |
| 294 | /** The off-by-default box that also invites the person to a workspace. */ |
| 295 | alsoJoin: "Also invite them to a workspace", |
| 296 | alsoJoinHint: |
| 297 | "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.", |
| 298 | /** When none are left: on the disabled button. */ |
| 299 | out: "You have used all your invites. Your friends can still sign up at g1t.sh without one.", |
| 300 | /** When none are left: under the count, which says so already. */ |
| 301 | outNote: "Your friends can still sign up at g1t.sh without one.", |
| 302 | } as const; |
| 303 | |
| 304 | type Membership = { slug: string; name?: string | null; role: "owner" | "member" }; |
| 305 | |
| 306 | /** One workspace an invite to g1t can also invite its person to. */ |
| 307 | export type BringInto = { slug: string; name: string }; |
| 308 | |
| 309 | /** |
| 310 | * The workspaces Invite a friend can also invite the person to, when "Also |
| 311 | * invite them to a workspace" is ticked: the ones the viewer owns that are |
| 312 | * on a paid plan. A free workspace invites no one, so it is not offered, and |
| 313 | * with none to offer the box is not shown at all. |
| 314 | */ |
| 315 | export function bringIntoChoices(memberships: readonly Membership[], free: readonly string[]): BringInto[] { |
| 316 | const isFree = new Set(free.map((slug) => slug.toLowerCase())); |
| 317 | return memberships |
| 318 | .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase())) |
| 319 | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug })); |
| 320 | } |
| 321 | |
| 322 | /** |
| 323 | * What the Invite a friend form sends to identity. The workspace goes with |
| 324 | * it only when "Also invite them to a workspace" (`also_join`) is ticked and |
| 325 | * it is one of `offered`: unticked, the invite is to g1t alone, whatever |
| 326 | * else the form held. |
| 327 | */ |
| 328 | export function inviteDraft( |
| 329 | form: { get(name: string): unknown }, |
| 330 | offered: readonly BringInto[] = [], |
| 331 | ): { email: string; message: string | null; join?: string; joinRole?: "owner" | "member" } { |
| 332 | const text = (name: string) => { |
| 333 | const value = form.get(name); |
| 334 | return typeof value === "string" ? value.trim() : ""; |
| 335 | }; |
| 336 | const draft: ReturnType<typeof inviteDraft> = { email: text("email"), message: text("message") || null }; |
| 337 | const join = text("join").toLowerCase(); |
| 338 | if (text("also_join") === "on" && join && offered.some((workspace) => workspace.slug === join)) { |
| 339 | draft.join = join; |
| 340 | draft.joinRole = text("join_role") === "owner" ? "owner" : "member"; |
| 341 | } |
| 342 | return draft; |
| 343 | } |
| 344 | |
| 345 | /** How many invites are left, in words: "18 of 20 invites left". */ |
| 346 | export function remainingLine(allowance: { limit: number; remaining: number }): string { |
| 347 | if (allowance.limit === 0) return "Invites to g1t are turned off on this installation"; |
| 348 | if (allowance.remaining === 0) return `You have used all ${allowance.limit} of your invites`; |
| 349 | return `${allowance.remaining} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`; |
| 350 | } |
| 351 | |
| 352 | /** The account menu's badge: "18 left", or nothing when the count is not known. */ |
| 353 | export function leftBadge(left: number | null | undefined): string | null { |
| 354 | return left == null ? null : `${left} left`; |
| 355 | } |
| 356 | |
| 357 | /** Which of a person's invites to g1t can be revoked, sent again, or copied. */ |
| 358 | export function inviteActions(invite: { status: Listed["status"]; code: string | null }): { |
| 359 | revoke: boolean; |
| 360 | resend: boolean; |
| 361 | copy: boolean; |
| 362 | } { |
| 363 | const pending = invite.status === "pending"; |
| 364 | return { |
| 365 | revoke: pending || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer", |
| 366 | resend: pending, |
| 367 | copy: pending && invite.code != null, |
| 368 | }; |
| 369 | } |
| 370 | |
| 371 | /** The words for a workspace's People page, the invitation to join it. */ |
| 372 | export function workspaceInviteCopy(name: string): { heading: string; hint: string } { |
| 373 | return { |
| 374 | heading: `Invite to ${name}`, |
| 375 | hint: `Search people on g1t by username or name, or enter an email address. They get an invitation to join ${name}, in their notifications and by email, and join only if they accept. If they do not have a g1t account yet, they sign up from the invitation first.`, |
| 376 | }; |
| 377 | } |
| 378 | |
| 379 | /** |
| 380 | * The Members page's filters, from its query: the role to show (`role=`: |
| 381 | * owners or members), whether two-factor authentication is on (`2fa=`: on |
| 382 | * or off; owners only see it), a team by slug (`team=`) and words to |
| 383 | * search by (`q=`, matched against the username and the name). Anything |
| 384 | * else is every member. |
| 385 | */ |
| 386 | export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string }; |
| 387 | |
| 388 | export function memberFilters(search: URLSearchParams | string): MemberFilters { |
| 389 | const params = typeof search === "string" ? new URLSearchParams(search) : search; |
| 390 | const role = params.get("role"); |
| 391 | const twoFactor = params.get("2fa"); |
| 392 | return { |
| 393 | role: role === "owner" || role === "member" ? role : "all", |
| 394 | twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all", |
| 395 | team: params.get("team")?.trim().toLowerCase() || null, |
| 396 | query: params.get("q")?.trim() ?? "", |
| 397 | }; |
| 398 | } |
| 399 | |
| 400 | type Listable = { |
| 401 | username: string; |
| 402 | name?: string | null; |
| 403 | role: "owner" | "member"; |
| 404 | two_factor?: boolean | null; |
| 405 | }; |
| 406 | |
| 407 | /** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */ |
| 408 | export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] { |
| 409 | const words = filters.query.toLowerCase().replace(/^@+/, ""); |
| 410 | return members.filter((member) => { |
| 411 | if (filters.role !== "all" && member.role !== filters.role) return false; |
| 412 | if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false; |
| 413 | if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false; |
| 414 | if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false; |
| 415 | return true; |
| 416 | }); |
| 417 | } |
| 418 | |
| 419 | /** |
| 420 | * Whether a sign-up or access form was filled in by a bot: the hidden |
| 421 | * `website` field people never see, or a form sent back faster than a |
| 422 | * person types. |
| 423 | */ |
| 424 | export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean { |
| 425 | const trap = form.get("website"); |
| 426 | if (typeof trap === "string" && trap.trim() !== "") return true; |
| 427 | const started = Number(form.get("started")); |
| 428 | return Number.isFinite(started) && started > 0 && now - started < 1500; |
| 429 | } |
| 430 | |
| 431 | /** |
| 432 | * A username to offer someone signing up with `email`: its local part, |
| 433 | * lowercased, with anything a username cannot hold made single hyphens, |
| 434 | * up to 39. They can type it in any case they like. Empty when nothing |
| 435 | * usable is left. Identity checks it is free. |
| 436 | */ |
| 437 | export function suggestUsername(email: string | null | undefined): string { |
| 438 | const local = (email ?? "").split("@")[0]?.split("+")[0] ?? ""; |
| 439 | return local |
| 440 | .toLowerCase() |
| 441 | .replace(/[^a-z0-9]+/g, "-") |
| 442 | .replace(/^-+|-+$/g, "") |
| 443 | .slice(0, 39) |
| 444 | .replace(/-+$/g, ""); |
| 445 | } |
| 446 | |
| 447 | type Lands = { workspace: { slug: string } | null; repository: { name: string } | null }; |
| 448 | |
| 449 | /** |
| 450 | * Where using an invite lands: the workspace it joins, the repository it |
| 451 | * gives access to, or nowhere in particular. |
| 452 | */ |
| 453 | export function landingFor(invite: Lands): string | null { |
| 454 | if (invite.workspace) return invite.workspace.slug.toLowerCase(); |
| 455 | if (invite.repository) return invite.repository.name.toLowerCase(); |
| 456 | return null; |
| 457 | } |
| 458 | |
| 459 | /** What someone who just joined is welcomed into, for one page view. */ |
| 460 | export const WELCOME_COOKIE = "g1t_welcome"; |
| 461 | |
| 462 | const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/; |
| 463 | |
| 464 | /** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */ |
| 465 | export function welcomeCookie(target: string, secure: boolean): string { |
| 466 | return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; |
| 467 | } |
| 468 | |
| 469 | /** The `Set-Cookie` value that ends the welcome, once it has been shown. */ |
| 470 | export function clearWelcome(secure: boolean): string { |
| 471 | return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; |
| 472 | } |
| 473 | |
| 474 | /** Whether the request's cookies welcome someone into `target`. */ |
| 475 | export function welcomes(cookieHeader: string | null, target: string): boolean { |
| 476 | for (const part of (cookieHeader ?? "").split(";")) { |
| 477 | const [key, ...rest] = part.trim().split("="); |
| 478 | if (key !== WELCOME_COOKIE) continue; |
| 479 | let value: string; |
| 480 | try { |
| 481 | value = decodeURIComponent(rest.join("=")); |
| 482 | } catch { |
| 483 | return false; |
| 484 | } |
| 485 | return TARGET.test(value) && value === target.toLowerCase(); |
| 486 | } |
| 487 | return false; |
| 488 | } |