Merge branch 'worktree-agent-a8385d293d42c913a'
35 files+1120−610/35 viewed
| 2982 | 2982 | } | |
| 2983 | 2983 | ||
| 2984 | 2984 | /// Runs the operation. One that found nothing, or was refused, under a | |
| 2985 | − | /// workspace slug that has since been renamed runs again under the | |
| 2986 | − | /// workspace's current slug, and one naming a repository by a path it | |
| 2987 | − | /// was transferred away from runs again at its path now; neither | |
| 2988 | − | /// outcome changed anything. | |
| 2985 | + | /// workspace slug that has since been renamed, or under an alias staff | |
| 2986 | + | /// set, runs again under the workspace's current slug, and one naming a | |
| 2987 | + | /// repository by a path it was transferred away from runs again at its | |
| 2988 | + | /// path now; neither outcome changed anything. | |
| 2989 | 2989 | pub async fn run( | |
| 2990 | 2990 | self, | |
| 2991 | 2991 | services: &Services, |
| 5 | 5 | //! repository (`owner/name`) or a workspace by a slug the workspace has | |
| 6 | 6 | //! since been renamed from is run again under the slug it has now, so | |
| 7 | 7 | //! scripts and agents written against the old address keep working while | |
| 8 | − | //! it redirects. | |
| 8 | + | //! it redirects. A workspace alias g1t's staff set (identity's aliases.rs: | |
| 9 | + | //! `g1t` for `flagon-io`) resolves the same way, for good, so responses | |
| 10 | + | //! name the workspace by its own slug. | |
| 9 | 11 | ||
| 10 | 12 | use g1t_contracts::identity::SlugArgs; | |
| 11 | 13 | use serde_json::Value; | |
| 107 | 109 | } | |
| 108 | 110 | ||
| 109 | 111 | #[test] | |
| 112 | + | fn an_alias_is_run_again_under_its_workspace() { | |
| 113 | + | let input = json!({ "repo": "g1t/g1t", "number": 7 }); | |
| 114 | + | assert_eq!(named_slug(&input), Some("g1t")); | |
| 115 | + | assert_eq!( | |
| 116 | + | rewrite(&input, "g1t", "flagon-io"), | |
| 117 | + | json!({ "repo": "flagon-io/g1t", "number": 7 }) | |
| 118 | + | ); | |
| 119 | + | assert_eq!( | |
| 120 | + | rewrite(&json!({ "workspace": "G1T" }), "g1t", "flagon-io"), | |
| 121 | + | json!({ "workspace": "flagon-io" }) | |
| 122 | + | ); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[test] | |
| 110 | 126 | fn rewrites_only_the_old_slug() { | |
| 111 | 127 | let input = json!({ "repo": "Acme/rocket", "workspace": "acme", "title": "acme/x" }); | |
| 112 | 128 | assert_eq!( |
| 70 | 70 | protected workspace. Both go in the workspace's audit log, as g1t, and in | |
| 71 | 71 | sudo's (`workspace_restored`, `workspace_purged`), naming the staff | |
| 72 | 72 | member. | |
| 73 | + | - **Aliases** (`/aliases`, under Customers): names that lead to a | |
| 74 | + | workspace, set by staff only; there is no way for a customer to make | |
| 75 | + | one, and nothing user-facing mentions them. `g1t`, the product's name, | |
| 76 | + | leads to `flagon-io`, Flagon, Inc. (seeded by identity's migration | |
| 77 | + | `0029_workspace_aliases.sql`), so nobody mistakes the trading name for | |
| 78 | + | the organization. Every address under an alias leads to the workspace: | |
| 79 | + | pages answer with a 301 to the same page (`/g1t/g1t/issues` to | |
| 80 | + | `/flagon-io/g1t/issues`), git over HTTPS is answered in place as the | |
| 81 | + | workspace's repository (pushes do not follow redirects), the API and MCP | |
| 82 | + | run the call again under the workspace's slug, and the package | |
| 83 | + | registries answer a 301 (308 for a publish). An alias points at the | |
| 84 | + | workspace's id, so it follows a rename; it goes when the workspace is | |
| 85 | + | purged. Each row shows the workspace, why the alias exists, and who added | |
| 86 | + | it and when. **Add** (`admin_set_alias`) takes the alias, the | |
| 87 | + | workspace's slug and why: identity refuses the site's own routes | |
| 88 | + | (`settings`, `api`…), anyone's username, a workspace's slug (deleted, or | |
| 89 | + | held after a rename for another workspace) and an existing alias. | |
| 90 | + | Reserved names such as `g1t` can be aliases, and an alias is nobody's to | |
| 91 | + | register or rename a workspace to while it exists. **Remove** | |
| 92 | + | (`admin_remove_alias`) needs a reason. Both go in sudo's audit log | |
| 93 | + | (`alias_added`, `alias_removed`), naming the staff member. `@g1t` in | |
| 94 | + | text still means g1t's agent: it links to how the agent works, never to | |
| 95 | + | `/g1t`. | |
| 73 | 96 | - **Enterprises**: customers that pay for several workspaces with one | |
| 74 | 97 | bill, one limit and one set of terms. Each has its workspaces (add or | |
| 75 | 98 | remove them), combined usage, terms, credits, ledger and audit log, and |
| 31 | 31 | Server, | |
| 32 | 32 | ShieldAlert, | |
| 33 | 33 | ShieldCheck, | |
| 34 | + | Signpost, | |
| 34 | 35 | Siren, | |
| 35 | 36 | Tags, | |
| 36 | 37 | Ticket, | |
| 72 | 73 | overages: TrendingUp, | |
| 73 | 74 | velocity: Activity, | |
| 74 | 75 | invites: Ticket, | |
| 76 | + | aliases: Signpost, | |
| 75 | 77 | customers: UsersRound, | |
| 76 | 78 | spend: Gauge, | |
| 77 | 79 | revenue: CircleDollarSign, |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { MAX_ALIAS_NOTE, parseNewAlias, parseRemovalReason } from "./aliases.ts"; | |
| 5 | + | ||
| 6 | + | test("an alias, its workspace and why are read as identity takes them", () => { | |
| 7 | + | assert.deepEqual(parseNewAlias(" G1T ", " Flagon-IO ", " The product's name, for Flagon, Inc. "), { | |
| 8 | + | ok: true, | |
| 9 | + | value: { alias: "g1t", workspace: "flagon-io", note: "The product's name, for Flagon, Inc." }, | |
| 10 | + | }); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("a malformed alias or workspace, or no reason, is refused before identity is asked", () => { | |
| 14 | + | for (const [alias, workspace, note] of [ | |
| 15 | + | ["g--1t", "flagon-io", "x"], | |
| 16 | + | ["-g1t", "flagon-io", "x"], | |
| 17 | + | ["g1t_inc", "flagon-io", "x"], | |
| 18 | + | ["g1t", "", "x"], | |
| 19 | + | ["g1t", "flagon io", "x"], | |
| 20 | + | ["g1t", "flagon-io", " "], | |
| 21 | + | ["flagon-io", "flagon-io", "x"], | |
| 22 | + | ["g1t", "flagon-io", "x".repeat(MAX_ALIAS_NOTE + 1)], | |
| 23 | + | ]) { | |
| 24 | + | assert.equal(parseNewAlias(alias!, workspace!, note!).ok, false, `${alias} ${workspace}`); | |
| 25 | + | } | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("removing an alias needs a reason", () => { | |
| 29 | + | assert.deepEqual(parseRemovalReason(" Flagon renamed the product "), { ok: true, value: "Flagon renamed the product" }); | |
| 30 | + | assert.equal(parseRemovalReason("").ok, false); | |
| 31 | + | assert.equal(parseRemovalReason("x".repeat(MAX_ALIAS_NOTE + 1)).ok, false); | |
| 32 | + | }); |
| 1 | + | /** | |
| 2 | + | * Workspace aliases, as the Aliases page reads its forms: a name staff | |
| 3 | + | * point at a workspace (`g1t` leads to `flagon-io`). Identity checks each | |
| 4 | + | * again, and knows what this cannot: whether a person or workspace has the | |
| 5 | + | * name. No Workers imports, so it can be tested under Node. | |
| 6 | + | */ | |
| 7 | + | import { isSlug, type Parsed } from "./forms.ts"; | |
| 8 | + | ||
| 9 | + | /** The longest note or reason, as identity allows. */ | |
| 10 | + | export const MAX_ALIAS_NOTE = 500; | |
| 11 | + | ||
| 12 | + | export type NewAlias = { alias: string; workspace: string; note: string }; | |
| 13 | + | ||
| 14 | + | function note(raw: string, missing: string): Parsed<string> { | |
| 15 | + | const value = raw.trim(); | |
| 16 | + | if (!value) return { ok: false, error: missing }; | |
| 17 | + | if ([...value].length > MAX_ALIAS_NOTE) return { ok: false, error: `Keep it to ${MAX_ALIAS_NOTE} characters.` }; | |
| 18 | + | return { ok: true, value }; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** The add form: an alias, the workspace it leads to, and why. */ | |
| 22 | + | export function parseNewAlias(alias: string, workspace: string, why: string): Parsed<NewAlias> { | |
| 23 | + | const name = alias.trim().toLowerCase(); | |
| 24 | + | if (!isSlug(name)) return { ok: false, error: "An alias uses lowercase letters, digits and single hyphens, up to 39 characters." }; | |
| 25 | + | const slug = workspace.trim().toLowerCase(); | |
| 26 | + | if (!isSlug(slug)) return { ok: false, error: "Give the workspace's slug, such as flagon-io." }; | |
| 27 | + | if (name === slug) return { ok: false, error: "An alias cannot be the workspace's own slug." }; | |
| 28 | + | const reason = note(why, "Say why the alias exists."); | |
| 29 | + | if (!reason.ok) return reason; | |
| 30 | + | return { ok: true, value: { alias: name, workspace: slug, note: reason.value } }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** The remove form's reason, for sudo's audit log. */ | |
| 34 | + | export function parseRemovalReason(why: string): Parsed<string> { | |
| 35 | + | return note(why, "Say why the alias is being removed."); | |
| 36 | + | } |
| 110 | 110 | // From identity: deleted workspaces staff restored or purged. | |
| 111 | 111 | workspace_restored: "Workspace restored", | |
| 112 | 112 | workspace_purged: "Workspace purged", | |
| 113 | + | // From identity: workspace aliases staff set or removed. | |
| 114 | + | alias_added: "Alias added", | |
| 115 | + | alias_removed: "Alias removed", | |
| 113 | 116 | // From the status page (apps/status), merged in by the Audit log page. | |
| 114 | 117 | incident_declared: "Incident declared", | |
| 115 | 118 | incident_detected: "Incident detected", |
| 10 | 10 | route("users/:username", "routes/user.tsx"), | |
| 11 | 11 | route("enterprises", "routes/enterprises.tsx"), | |
| 12 | 12 | route("invites", "routes/invites.tsx"), | |
| 13 | + | route("aliases", "routes/aliases.tsx"), | |
| 13 | 14 | route("enterprises/new", "routes/new-enterprise.tsx"), | |
| 14 | 15 | route("enterprises/:id", "routes/enterprise.tsx"), | |
| 15 | 16 | route("reach-out", "routes/reach-out.tsx"), |
| 1 | + | import { ArrowRight, Signpost } from "lucide-react"; | |
| 2 | + | import { Link, data, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { WorkspaceAlias } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/aliases"; | |
| 7 | + | import { Button, EmptyState, Field, Input, Notice, PageHeader, Section, Textarea, When } from "~/components/ui"; | |
| 8 | + | import { MAX_ALIAS_NOTE, parseNewAlias, parseRemovalReason } from "~/lib/aliases"; | |
| 9 | + | import { text } from "~/lib/forms"; | |
| 10 | + | import { identity } from "~/lib/services.server"; | |
| 11 | + | import { settle } from "~/lib/settle"; | |
| 12 | + | import { requireStaff } from "~/lib/staff"; | |
| 13 | + | ||
| 14 | + | export const meta: Route.MetaFunction = () => [{ title: "Aliases · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 15 | + | ||
| 16 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 17 | + | requireStaff(context); | |
| 18 | + | const url = new URL(request.url); | |
| 19 | + | const aliases = await settle(identity.aliases()); | |
| 20 | + | const done = url.searchParams.get("done"); | |
| 21 | + | const name = url.searchParams.get("alias") ?? ""; | |
| 22 | + | return { | |
| 23 | + | aliases: aliases.ok ? aliases.value : [], | |
| 24 | + | error: aliases.ok ? null : aliases.error, | |
| 25 | + | done: done === "added" ? `${name} is an alias now.` : done === "removed" ? `Removed ${name}.` : null, | |
| 26 | + | }; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type ActionData = { error: string; alias?: string; values?: { alias: string; workspace: string; note: string } }; | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Adding and removing. Identity checks each again (a person's or a | |
| 33 | + | * workspace's name is never an alias) and records it in sudo's audit log, | |
| 34 | + | * naming the staff member. | |
| 35 | + | */ | |
| 36 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 37 | + | const staff = requireStaff(context); | |
| 38 | + | const form = await request.formData(); | |
| 39 | + | const back = (done: string, alias: string) => redirect(`/aliases?done=${done}&alias=${encodeURIComponent(alias)}`); | |
| 40 | + | switch (text(form, "intent")) { | |
| 41 | + | case "add": { | |
| 42 | + | const values = { alias: text(form, "alias"), workspace: text(form, "workspace"), note: text(form, "note") }; | |
| 43 | + | const parsed = parseNewAlias(values.alias, values.workspace, values.note); | |
| 44 | + | if (!parsed.ok) return data<ActionData>({ error: parsed.error, values }, { status: 422 }); | |
| 45 | + | const { alias, workspace, note } = parsed.value; | |
| 46 | + | const result = await identity.setAlias(alias, workspace, note, staff.email); | |
| 47 | + | if (!result.ok) return data<ActionData>({ error: result.error.message, values }, { status: 422 }); | |
| 48 | + | throw back("added", alias); | |
| 49 | + | } | |
| 50 | + | case "remove": { | |
| 51 | + | const alias = text(form, "alias"); | |
| 52 | + | const reason = parseRemovalReason(text(form, "reason")); | |
| 53 | + | if (!reason.ok) return data<ActionData>({ error: reason.error, alias }, { status: 422 }); | |
| 54 | + | const result = await identity.removeAlias(alias, reason.value, staff.email); | |
| 55 | + | if (!result.ok) return data<ActionData>({ error: result.error.message, alias }, { status: 422 }); | |
| 56 | + | throw back("removed", alias); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return data<ActionData>({ error: "Unknown action." }, { status: 400 }); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | export default function Aliases({ loaderData, actionData }: Route.ComponentProps) { | |
| 63 | + | const { aliases, error, done } = loaderData; | |
| 64 | + | const adding = actionData && !actionData.alias ? actionData : null; | |
| 65 | + | return ( | |
| 66 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 67 | + | <PageHeader | |
| 68 | + | title="Aliases" | |
| 69 | + | description="Names that lead to a workspace. Every address under an alias (pages, git, the API, packages) leads to the workspace under its own name, and the alias follows it through renames. Only staff set them, for a company's trading name such as g1t for Flagon, Inc.; customers cannot make one." | |
| 70 | + | /> | |
| 71 | + | <div className="mt-6 space-y-3"> | |
| 72 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 73 | + | {error && <Notice tone="error">Identity did not answer: {error}</Notice>} | |
| 74 | + | </div> | |
| 75 | + | <div className="mt-6"> | |
| 76 | + | {aliases.length === 0 ? ( | |
| 77 | + | <EmptyState title="No aliases">An alias appears here once staff add one below.</EmptyState> | |
| 78 | + | ) : ( | |
| 79 | + | <ul className="space-y-3"> | |
| 80 | + | {aliases.map((alias) => ( | |
| 81 | + | <AliasRow key={alias.alias} alias={alias} error={actionData && actionData.alias === alias.alias ? actionData.error : null} /> | |
| 82 | + | ))} | |
| 83 | + | </ul> | |
| 84 | + | )} | |
| 85 | + | </div> | |
| 86 | + | <Section | |
| 87 | + | className="mt-6" | |
| 88 | + | title="Add an alias" | |
| 89 | + | description="A name nobody has: never a person's username or a workspace's slug, and not one of the site's own routes. Reserved names such as g1t can be. It is nobody's to register while it is an alias." | |
| 90 | + | > | |
| 91 | + | <form method="post" className="space-y-3"> | |
| 92 | + | <input type="hidden" name="intent" value="add" /> | |
| 93 | + | <div className="grid gap-3 sm:grid-cols-2"> | |
| 94 | + | <Field label="Alias"> | |
| 95 | + | <Input name="alias" required maxLength={39} spellCheck={false} placeholder="acme-corp" defaultValue={adding?.values?.alias} className="font-mono" /> | |
| 96 | + | </Field> | |
| 97 | + | <Field label="Leads to workspace"> | |
| 98 | + | <Input | |
| 99 | + | name="workspace" | |
| 100 | + | required | |
| 101 | + | maxLength={39} | |
| 102 | + | spellCheck={false} | |
| 103 | + | placeholder="acme" | |
| 104 | + | defaultValue={adding?.values?.workspace} | |
| 105 | + | className="font-mono" | |
| 106 | + | /> | |
| 107 | + | </Field> | |
| 108 | + | </div> | |
| 109 | + | <Field label="Why" hint="Kept with the alias, and in the audit log."> | |
| 110 | + | <Textarea | |
| 111 | + | name="note" | |
| 112 | + | rows={2} | |
| 113 | + | maxLength={MAX_ALIAS_NOTE} | |
| 114 | + | required | |
| 115 | + | placeholder="e.g. Acme's trading name, asked for by their CTO." | |
| 116 | + | defaultValue={adding?.values?.note} | |
| 117 | + | /> | |
| 118 | + | </Field> | |
| 119 | + | {adding && <Notice tone="error">{adding.error}</Notice>} | |
| 120 | + | <div className="flex justify-end"> | |
| 121 | + | <Button type="submit" variant="lavender"> | |
| 122 | + | <Signpost size={14} /> | |
| 123 | + | Add alias | |
| 124 | + | </Button> | |
| 125 | + | </div> | |
| 126 | + | </form> | |
| 127 | + | </Section> | |
| 128 | + | </main> | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | function AliasRow({ alias, error }: { alias: WorkspaceAlias; error: string | null }) { | |
| 133 | + | return ( | |
| 134 | + | <li id={`alias-${alias.alias}`} className="scroll-mt-20 rounded-lg border border-line bg-surface p-4 sm:p-5"> | |
| 135 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 136 | + | <span className="font-mono font-medium">{alias.alias}</span> | |
| 137 | + | <ArrowRight size={14} className="text-faint" aria-label="leads to" /> | |
| 138 | + | <Link to={`/workspaces/${alias.workspace}`} className="font-mono hover:underline"> | |
| 139 | + | {alias.workspace} | |
| 140 | + | </Link> | |
| 141 | + | <span className="text-sm text-muted">{alias.workspaceName}</span> | |
| 142 | + | </div> | |
| 143 | + | <p className="mt-2 text-sm text-fg-soft">{alias.note || <span className="text-faint">No note.</span>}</p> | |
| 144 | + | <p className="mt-1 text-xs text-muted"> | |
| 145 | + | Added by <span className="text-fg-soft">{alias.createdBy}</span> <When at={alias.createdAt} time /> | |
| 146 | + | </p> | |
| 147 | + | {error && ( | |
| 148 | + | <div className="mt-3"> | |
| 149 | + | <Notice tone="error">{error}</Notice> | |
| 150 | + | </div> | |
| 151 | + | )} | |
| 152 | + | <form method="post" action={`/aliases#alias-${alias.alias}`} className="mt-4 flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-end"> | |
| 153 | + | <input type="hidden" name="intent" value="remove" /> | |
| 154 | + | <input type="hidden" name="alias" value={alias.alias} /> | |
| 155 | + | <label className="grid flex-1 gap-1 text-xs text-muted"> | |
| 156 | + | <span>Why remove it</span> | |
| 157 | + | <Input name="reason" required maxLength={MAX_ALIAS_NOTE} aria-label={`Why remove ${alias.alias}`} /> | |
| 158 | + | </label> | |
| 159 | + | <Button type="submit" variant="danger"> | |
| 160 | + | Remove | |
| 161 | + | </Button> | |
| 162 | + | </form> | |
| 163 | + | </li> | |
| 164 | + | ); | |
| 165 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { rehypeReferences } from "./markdown-plugins.ts"; | |
| 4 | + | import { G1T_MENTION_HREF, rehypeReferences } from "./markdown-plugins.ts"; | |
| 5 | 5 | ||
| 6 | 6 | type Node = { type: string; value?: string; tagName?: string; properties?: Record<string, unknown>; children?: Node[] }; | |
| 7 | 7 | ||
| 19 | 19 | assert.deepEqual(links("me@example.com"), []); | |
| 20 | 20 | }); | |
| 21 | 21 | ||
| 22 | + | test("@g1t is g1t's agent, never the workspace alias at /g1t", () => { | |
| 23 | + | assert.deepEqual(links("@g1t fix this, then ask @G1T again"), [ | |
| 24 | + | [G1T_MENTION_HREF, "@g1t"], | |
| 25 | + | [G1T_MENTION_HREF, "@G1T"], | |
| 26 | + | ]); | |
| 27 | + | assert.ok(!G1T_MENTION_HREF.startsWith("/")); | |
| 28 | + | // Names that only start with g1t are anyone's. | |
| 29 | + | assert.deepEqual(links("@g1t-fans"), [["/g1t-fans", "@g1t-fans"]]); | |
| 30 | + | }); | |
| 31 | + | ||
| 22 | 32 | test("@workspace/team links to the team", () => { | |
| 23 | 33 | assert.deepEqual(links("cc @acme/backend, @ana"), [ | |
| 24 | 34 | ["/acme/-/teams/backend", "@acme/backend"], |
| 60 | 60 | }; | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | /** | |
| 64 | + | * Where `@g1t` leads: g1t's own agent, which has no profile. Never `/g1t`, | |
| 65 | + | * which staff made an alias of Flagon, Inc.'s workspace: mentioning g1t | |
| 66 | + | * always means the agent. | |
| 67 | + | */ | |
| 68 | + | export const G1T_MENTION_HREF = "https://docs.g1t.sh/guides/working-with-g1t/"; | |
| 69 | + | ||
| 63 | 70 | /** `owner/repo#12`, `#12`, `@workspace/team`, `@name` and commit hashes, in one pass. */ | |
| 64 | 71 | const REFERENCE = | |
| 65 | 72 | /(?<![\w/@#])(?:([a-z0-9][a-z0-9-]*\/[a-z0-9._-]+)#(\d+)|#(\d+)|@([a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38})\/([a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,59})|@([a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38})|([0-9a-f]{7,40}))(?![\w-])/gi; | |
| 76 | 83 | /** | |
| 77 | 84 | * Links what a forge's text refers to: `#12` to an issue or pull request | |
| 78 | 85 | * in this repository (the issue page sends a pull request's number on to | |
| 79 | − | * it), `owner/repo#12` to one elsewhere, `@name` to a person or workspace, | |
| 80 | − | * and a commit hash to its commit. Nothing inside code or a link changes. | |
| 86 | + | * it), `owner/repo#12` to one elsewhere, `@name` to a person or workspace | |
| 87 | + | * (`@g1t` to how g1t's agent works), and a commit hash to its commit. | |
| 88 | + | * Nothing inside code or a link changes. | |
| 81 | 89 | */ | |
| 82 | 90 | export function rehypeReferences(options: { repo?: MarkdownRepo }) { | |
| 83 | 91 | const { repo } = options; | |
| 106 | 114 | } else if (workspace && team) { | |
| 107 | 115 | replacement = link(`/${workspace.toLowerCase()}/-/teams/${team.toLowerCase()}`, whole, "team"); | |
| 108 | 116 | } else if (name) { | |
| 109 | − | replacement = link(`/${name.toLowerCase()}`, whole, "mention"); | |
| 117 | + | const href = name.toLowerCase() === "g1t" ? G1T_MENTION_HREF : `/${name.toLowerCase()}`; | |
| 118 | + | replacement = link(href, whole, "mention"); | |
| 110 | 119 | } else if (hash && repo && /\d/.test(hash) && /[a-f]/i.test(hash)) { | |
| 111 | 120 | replacement = link(`/${repo.namespace}/${repo.name}/commit/${hash}`, hash.slice(0, 7), "commit"); | |
| 112 | 121 | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { servicePath } from "./registry-paths.ts"; | |
| 4 | + | import { registryWorkspace, servicePath } from "./registry-paths.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("the container registry's paths go to the packages service", () => { | |
| 7 | 7 | for (const path of [ | |
| 119 | 119 | assert.equal(servicePath(path), null, path); | |
| 120 | 120 | } | |
| 121 | 121 | }); | |
| 122 | + | ||
| 123 | + | test("a registry request names its workspace, and can be moved to another", () => { | |
| 124 | + | const cases: [string, string, string][] = [ | |
| 125 | + | ["/-/cargo/g1t/index/config.json", "g1t", "/-/cargo/flagon-io/index/config.json"], | |
| 126 | + | ["/-/composer/g1t/packages.json", "g1t", "/-/composer/flagon-io/packages.json"], | |
| 127 | + | ["/-/maven/g1t/io/flagon/sdk/1.0/sdk-1.0.jar", "g1t", "/-/maven/flagon-io/io/flagon/sdk/1.0/sdk-1.0.jar"], | |
| 128 | + | ["/-/nuget/g1t/v3/index.json", "g1t", "/-/nuget/flagon-io/v3/index.json"], | |
| 129 | + | ["/-/rubygems/g1t/info/sdk", "g1t", "/-/rubygems/flagon-io/info/sdk"], | |
| 130 | + | ["/-/npm/@g1t%2fcli", "g1t", "/-/npm/@flagon-io%2fcli"], | |
| 131 | + | ["/-/npm/@g1t/cli/-/cli-1.0.0.tgz", "g1t", "/-/npm/@flagon-io/cli/-/cli-1.0.0.tgz"], | |
| 132 | + | ["/-/npm/-/package/@g1t%2Fcli/dist-tags/next", "g1t", "/-/npm/-/package/@flagon-io%2Fcli/dist-tags/next"], | |
| 133 | + | ["/v2/g1t/runner/manifests/latest", "g1t", "/v2/flagon-io/runner/manifests/latest"], | |
| 134 | + | ["/v2/G1T/runner/blobs/uploads/upl_1", "g1t", "/v2/flagon-io/runner/blobs/uploads/upl_1"], | |
| 135 | + | ]; | |
| 136 | + | for (const [path, slug, moved] of cases) { | |
| 137 | + | const named = registryWorkspace(path); | |
| 138 | + | assert.equal(named?.slug, slug, path); | |
| 139 | + | assert.equal(named?.under("flagon-io"), moved, path); | |
| 140 | + | } | |
| 141 | + | for (const path of ["/v2", "/v2/", "/v2/token", "/-/npm", "/-/npm/-/whoami", "/-/npm/-/ping", "/-/npm/unscoped"]) { | |
| 142 | + | assert.equal(registryWorkspace(path), null, path); | |
| 143 | + | } | |
| 144 | + | }); |
| 29 | 29 | if (GIT_PATH.test(pathname)) return "git"; | |
| 30 | 30 | return null; | |
| 31 | 31 | } | |
| 32 | + | ||
| 33 | + | /** | |
| 34 | + | * The workspace a registry request names, and `under`, the same path under | |
| 35 | + | * another workspace: `/-/cargo/<workspace>/…` and the other per-workspace | |
| 36 | + | * registries, npm's `@<workspace>` scope, and a container image's first | |
| 37 | + | * segment. Null for a path that names none, such as `/v2/token`. | |
| 38 | + | */ | |
| 39 | + | export type RegistryWorkspace = { slug: string; under: (slug: string) => string }; | |
| 40 | + | ||
| 41 | + | const WORKSPACE_IN_PATH = [ | |
| 42 | + | /^(\/-\/(?:composer|cargo|maven|nuget|rubygems)\/)([^/]+)(\/.*)?$/, | |
| 43 | + | /^(\/-\/npm\/(?:-\/package\/)?@)([^/%]+)((?:\/|%2[fF]).*)$/, | |
| 44 | + | /^(\/v2\/)([^/]+)(\/.+)$/, | |
| 45 | + | ]; | |
| 46 | + | ||
| 47 | + | export function registryWorkspace(pathname: string): RegistryWorkspace | null { | |
| 48 | + | for (const pattern of WORKSPACE_IN_PATH) { | |
| 49 | + | const match = pattern.exec(pathname); | |
| 50 | + | if (!match) continue; | |
| 51 | + | const [, before, slug, after = ""] = match; | |
| 52 | + | return { slug: slug!.toLowerCase(), under: (to) => `${before}${to}${after}` }; | |
| 53 | + | } | |
| 54 | + | return null; | |
| 55 | + | } |
| 1 | 1 | import { redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | − | import { isValidNamespace } from "@g1t/contracts"; | |
| 3 | + | import { isNamespaceShaped } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Viewer } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { identity, repos } from "./services.server"; | |
| 8 | + | import { underWorkspace } from "./workspace-nav"; | |
| 8 | 9 | ||
| 9 | 10 | /** | |
| 10 | 11 | * A workspace that was renamed keeps its old address as a redirect for | |
| 11 | − | * `SLUG_HOLD_DAYS`. Call this where a page is about to 404 on its first | |
| 12 | − | * path segment: if `slug` is an old name, it throws a 301 to the same | |
| 13 | − | * address (path and query) under the current one. Otherwise it returns, and | |
| 14 | − | * the caller 404s as before. Only the not-found path pays for the lookup. | |
| 12 | + | * `SLUG_HOLD_DAYS`, and an alias g1t's staff set (identity's aliases.rs: | |
| 13 | + | * `g1t` for `flagon-io`) leads to its workspace for good. Call this where a | |
| 14 | + | * page is about to 404 on its first path segment: if `slug` is an old name | |
| 15 | + | * or an alias, it throws a 301 to the same page (path and query) under the | |
| 16 | + | * workspace's slug now. Otherwise it returns, and the caller 404s as | |
| 17 | + | * before. Only the not-found path pays for the lookup. | |
| 15 | 18 | */ | |
| 16 | 19 | export async function redirectIfRenamed(request: Request, slug: string): Promise<void> { | |
| 17 | 20 | const old = slug.toLowerCase(); | |
| 18 | − | // Nothing that could never have been a workspace's name is looked up. | |
| 19 | − | if (!isValidNamespace(old)) return; | |
| 21 | + | // Nothing that could never have been a workspace's name or an alias is | |
| 22 | + | // looked up. Reserved names such as `g1t` can be aliases. | |
| 23 | + | if (!isNamespaceShaped(old)) return; | |
| 20 | 24 | let current: string | null = null; | |
| 21 | 25 | try { | |
| 22 | 26 | current = await identity.resolveSlug(old); | |
| 26 | 30 | } | |
| 27 | 31 | if (!current || current === old) return; | |
| 28 | 32 | const url = new URL(request.url); | |
| 29 | − | const segments = url.pathname.split("/"); | |
| 30 | − | // segments[0] is the empty string before the leading slash. | |
| 31 | − | segments[1] = current; | |
| 32 | − | throw redirect(segments.join("/") + url.search, 301); | |
| 33 | + | throw redirect(underWorkspace(url.pathname, url.search, current), 301); | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | /** |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/not-found"; | |
| 4 | 4 | import { redirectIfRenamed } from "../lib/renamed.server"; | |
| 5 | + | import { pagePath } from "../lib/workspace-nav"; | |
| 5 | 6 | ||
| 6 | 7 | /** | |
| 7 | 8 | * Any address no other route matches. Throwing the 404 from a route, rather | |
| 8 | 9 | * than leaving the router to, means the root loader still runs, so someone | |
| 9 | 10 | * signed in sees the page in their own sidebar and not the public frame. | |
| 10 | 11 | * | |
| 11 | − | * An address under a renamed workspace's old name is sent to the new one | |
| 12 | − | * first, so deep links keep working the same as its pages do. | |
| 12 | + | * An address under a renamed workspace's old name, or under an alias, is | |
| 13 | + | * sent to the workspace's name first, so deep links keep working the same | |
| 14 | + | * as its pages do. | |
| 13 | 15 | */ | |
| 14 | 16 | export async function loader({ request }: Route.LoaderArgs) { | |
| 15 | − | const first = new URL(request.url).pathname.split("/")[1]; | |
| 17 | + | const first = pagePath(new URL(request.url).pathname).split("/")[1]; | |
| 16 | 18 | if (first) await redirectIfRenamed(request, first); | |
| 17 | 19 | throw data(null, { status: 404 }); | |
| 18 | 20 | } |
| 1 | 1 | import { createRequestHandler } from "react-router"; | |
| 2 | 2 | ||
| 3 | + | import { identityClient, isNamespaceShaped } from "@g1t/contracts"; | |
| 4 | + | ||
| 3 | 5 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 4 | 6 | import { goImport } from "../app/lib/go-get"; | |
| 5 | 7 | import { repositoryOfPage, stillPublic } from "../app/lib/public-cache"; | |
| 6 | − | import { servicePath } from "../app/lib/registry-paths"; | |
| 8 | + | import { registryWorkspace, servicePath } from "../app/lib/registry-paths"; | |
| 7 | 9 | ||
| 8 | 10 | const requestHandler = createRequestHandler( | |
| 9 | 11 | () => import("virtual:react-router/server-build"), | |
| 162 | 164 | /** | |
| 163 | 165 | * A registry request, answered by the packages service as it is: its | |
| 164 | 166 | * redirects (a large blob sent to storage) go back to the client, which | |
| 165 | − | * follows them itself. | |
| 167 | + | * follows them itself. One that found nothing under a workspace's old name | |
| 168 | + | * or an alias staff set (`g1t` for `flagon-io`) is sent to the same path | |
| 169 | + | * under the workspace's name: only the not-found answer pays for the lookup. | |
| 166 | 170 | */ | |
| 167 | 171 | async function proxyPackages(env: Env, request: Request): Promise<Response> { | |
| 168 | 172 | const started = Date.now(); | |
| 169 | 173 | const answer = await env.PACKAGES.fetch(new Request(request, { redirect: "manual" })); | |
| 170 | − | const response = new Response(answer.body, answer); | |
| 174 | + | const moved = answer.status === 404 ? await registryMoved(env, request) : null; | |
| 175 | + | const response = moved ?? new Response(answer.body, answer); | |
| 171 | 176 | response.headers.append("server-timing", `packages;dur=${Date.now() - started}`); | |
| 172 | 177 | return response; | |
| 173 | 178 | } | |
| 174 | 179 | ||
| 180 | + | /** Where a registry request under an alias or old name goes now, or null. */ | |
| 181 | + | async function registryMoved(env: Env, request: Request): Promise<Response | null> { | |
| 182 | + | const url = new URL(request.url); | |
| 183 | + | const named = registryWorkspace(url.pathname); | |
| 184 | + | if (!named || !isNamespaceShaped(named.slug)) return null; | |
| 185 | + | let current: string | null = null; | |
| 186 | + | try { | |
| 187 | + | current = await identityClient(env.IDENTITY).resolveSlug(named.slug); | |
| 188 | + | } catch { | |
| 189 | + | return null; | |
| 190 | + | } | |
| 191 | + | if (!current || current === named.slug) return null; | |
| 192 | + | const get = request.method === "GET" || request.method === "HEAD"; | |
| 193 | + | // 308 keeps a publish a PUT, for the clients that follow it. | |
| 194 | + | return new Response(null, { status: get ? 301 : 308, headers: { location: named.under(current) + url.search } }); | |
| 195 | + | } | |
| 196 | + | ||
| 175 | 197 | /** | |
| 176 | 198 | * An uploaded avatar. Its address is its hash, so it never changes and is | |
| 177 | 199 | * kept for good. It is served as nothing but an image: the stored type, |
| 560 | 560 | // `resolve_slug` takes `SlugArgs` and returns `Option<String>`: the | |
| 561 | 561 | // workspace's current slug when `slug` is one it was renamed from within | |
| 562 | 562 | // the last `SLUG_HOLD_DAYS`, and null otherwise (including for a slug that | |
| 563 | − | // is in use). | |
| 563 | + | // is in use), or the workspace's slug when `slug` is one of its aliases. | |
| 564 | + | ||
| 565 | + | // `resolve_alias` takes `SlugArgs` and returns `Option<String>`: the slug | |
| 566 | + | // now of the workspace `slug` is an alias of, and null when it is none. | |
| 567 | + | // Aliases are set by g1t's staff only: `g1t` is Flagon, Inc.'s `flagon-io`. | |
| 568 | + | // An alias follows its workspace through renames. | |
| 569 | + | ||
| 570 | + | /// `admin_aliases` takes no arguments (`{}`) and returns | |
| 571 | + | /// `Vec<WorkspaceAlias>`, by alias. Staff only. | |
| 572 | + | /// | |
| 573 | + | /// A name staff point at a workspace, so that its addresses (pages, git, | |
| 574 | + | /// the API, packages) lead to the workspace under its own name. | |
| 575 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 576 | + | #[serde(rename_all = "camelCase")] | |
| 577 | + | pub struct WorkspaceAlias { | |
| 578 | + | pub alias: String, | |
| 579 | + | pub workspace_id: String, | |
| 580 | + | /// The workspace's slug and name now. | |
| 581 | + | pub workspace: String, | |
| 582 | + | pub workspace_name: String, | |
| 583 | + | /// Why it exists, as staff wrote it. | |
| 584 | + | pub note: String, | |
| 585 | + | /// The staff member who set it, or `migration`. | |
| 586 | + | pub created_by: String, | |
| 587 | + | /// RFC 3339. | |
| 588 | + | pub created_at: String, | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | /// `admin_set_alias`: points `alias` at the workspace whose slug is | |
| 592 | + | /// `workspace`. The alias must have a namespace's shape, must not be one of | |
| 593 | + | /// the site's routes, and must not be anyone's username, a workspace's slug | |
| 594 | + | /// (deleted, or held after a rename) or another alias. `note` is required: | |
| 595 | + | /// it is the reason, kept with the alias and in sudo's audit log. Staff | |
| 596 | + | /// only. Returns `Outcome<WorkspaceAlias>`. | |
| 597 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 598 | + | #[serde(rename_all = "camelCase")] | |
| 599 | + | pub struct AdminSetAliasArgs { | |
| 600 | + | pub alias: String, | |
| 601 | + | pub workspace: String, | |
| 602 | + | pub note: String, | |
| 603 | + | pub staff: String, | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | /// `admin_remove_alias`: the alias stops leading anywhere, and the name is | |
| 607 | + | /// nobody's again unless it is reserved. `reason` goes in sudo's audit log. | |
| 608 | + | /// Staff only. Returns `Outcome<bool>`. | |
| 609 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 610 | + | #[serde(rename_all = "camelCase")] | |
| 611 | + | pub struct AdminRemoveAliasArgs { | |
| 612 | + | pub alias: String, | |
| 613 | + | pub reason: String, | |
| 614 | + | pub staff: String, | |
| 615 | + | } | |
| 564 | 616 | ||
| 565 | 617 | /// `set_workspace_avatar`: owners only. `image` is the file's bytes in | |
| 566 | 618 | /// base64: PNG, JPEG, WebP or GIF, at most `MAX_AVATAR_BYTES`. Null removes |
| 38 | 38 | pub mod work; | |
| 39 | 39 | ||
| 40 | 40 | pub use ids::new_id; | |
| 41 | − | pub use names::{claimable_namespace, is_reserved_name, is_valid_namespace, is_valid_repo_name}; | |
| 41 | + | pub use names::{ | |
| 42 | + | aliasable_name, claimable_namespace, is_namespace_shaped, is_reserved_name, is_route_name, is_valid_namespace, | |
| 43 | + | is_valid_repo_name, | |
| 44 | + | }; | |
| 42 | 45 | pub use outcome::{Failure, FailureCode, Outcome}; | |
| 43 | 46 | ||
| 44 | 47 | use serde::{Deserialize, Serialize}; |
| 1 | − | /// Routes and reserved words that may not be registered as usernames. | |
| 2 | − | const RESERVED: &[&str] = &[ | |
| 1 | + | /// The site's own routes: first path segments that are never a workspace's, | |
| 2 | + | /// so nobody may register them, and no alias can be reached at them. | |
| 3 | + | const ROUTES: &[&str] = &[ | |
| 3 | 4 | "api", | |
| 4 | 5 | "mcp", | |
| 5 | 6 | "login", | |
| 19 | 20 | "avatars", | |
| 20 | 21 | "docs", | |
| 21 | 22 | "explore", | |
| 22 | − | // g1t itself, and the name its agent once went by: everything g1t | |
| 23 | − | // does is shown as `g1t`, so nobody else may be called either. | |
| 24 | − | "g1t", | |
| 25 | − | "g1t-agent", | |
| 26 | 23 | "about", | |
| 27 | 24 | "pricing", | |
| 28 | 25 | "terms", | |
| 50 | 47 | "notifications", | |
| 51 | 48 | ]; | |
| 52 | 49 | ||
| 50 | + | /// g1t itself, and the name its agent once went by: everything g1t does is | |
| 51 | + | /// shown as `g1t`, so nobody else may be called either. Not routes: staff | |
| 52 | + | /// may point one at a workspace as an alias (identity's `aliases.rs`). | |
| 53 | + | const OWN: &[&str] = &["g1t", "g1t-agent"]; | |
| 54 | + | ||
| 53 | 55 | /// Whether `value`, whatever its case, is a name nobody can register or | |
| 54 | 56 | /// rename a workspace to: a route, or g1t's own. | |
| 55 | 57 | pub fn is_reserved_name(value: &str) -> bool { | |
| 58 | + | is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim())) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Whether `value`, whatever its case, is one of the site's own routes. | |
| 62 | + | pub fn is_route_name(value: &str) -> bool { | |
| 56 | 63 | let value = value.trim(); | |
| 57 | − | RESERVED.iter().any(|reserved| reserved.eq_ignore_ascii_case(value)) | |
| 64 | + | ROUTES.iter().any(|route| route.eq_ignore_ascii_case(value)) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Whether `value` has a namespace's shape: letters, digits and single | |
| 68 | + | /// hyphens, not starting or ending with a hyphen, at most 39 characters. | |
| 69 | + | /// Reserved names have it too; see [`is_valid_namespace`]. | |
| 70 | + | pub fn is_namespace_shaped(value: &str) -> bool { | |
| 71 | + | let bytes = value.as_bytes(); | |
| 72 | + | !bytes.is_empty() | |
| 73 | + | && bytes.len() <= 39 | |
| 74 | + | && bytes | |
| 75 | + | .iter() | |
| 76 | + | .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') | |
| 77 | + | && !value.starts_with('-') | |
| 78 | + | && !value.ends_with('-') | |
| 79 | + | && !value.contains("--") | |
| 58 | 80 | } | |
| 59 | 81 | ||
| 82 | + | /// A workspace alias as staff typed it, trimmed and lowercased, if it can | |
| 83 | + | /// be one: shaped like a namespace and not one of the site's routes, which | |
| 84 | + | /// would always answer first. Reserved names such as `g1t` can be; whether | |
| 85 | + | /// a person or workspace already has it is identity's to check. | |
| 86 | + | pub fn aliasable_name(value: &str) -> Option<String> { | |
| 87 | + | let value = value.trim().to_lowercase(); | |
| 88 | + | (is_namespace_shaped(&value) && !is_route_name(&value)).then_some(value) | |
| 89 | + | } | |
| 90 | + | ||
| 60 | 91 | /// A username or workspace slug as someone typed it, trimmed and | |
| 61 | 92 | /// lowercased, if it can be registered: what signing up, signing up with | |
| 62 | 93 | /// GitHub and creating a workspace each take. None when it is malformed or | |
| 69 | 100 | /// Namespaces follow GitHub's rules: letters, digits and single hyphens, | |
| 70 | 101 | /// not starting or ending with a hyphen, at most 39 characters. | |
| 71 | 102 | pub fn is_valid_namespace(value: &str) -> bool { | |
| 72 | − | let bytes = value.as_bytes(); | |
| 73 | − | !bytes.is_empty() | |
| 74 | − | && bytes.len() <= 39 | |
| 75 | − | && bytes | |
| 76 | − | .iter() | |
| 77 | − | .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') | |
| 78 | − | && !value.starts_with('-') | |
| 79 | − | && !value.ends_with('-') | |
| 80 | − | && !value.contains("--") | |
| 81 | − | && !is_reserved_name(value) | |
| 103 | + | is_namespace_shaped(value) && !is_reserved_name(value) | |
| 82 | 104 | } | |
| 83 | 105 | ||
| 84 | 106 | pub fn is_valid_repo_name(value: &str) -> bool { | |
| 114 | 136 | assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana")); | |
| 115 | 137 | assert_eq!(claimable_namespace("an--a"), None); | |
| 116 | 138 | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn g1t_can_be_an_alias_but_a_route_cannot() { | |
| 142 | + | assert_eq!(aliasable_name(" G1T ").as_deref(), Some("g1t")); | |
| 143 | + | assert_eq!(aliasable_name("acme-corp").as_deref(), Some("acme-corp")); | |
| 144 | + | for name in ["settings", "api", "login", "Explore", "-acme", "ac--me", "acme_inc", "", "a.b"] { | |
| 145 | + | assert_eq!(aliasable_name(name), None, "{name}"); | |
| 146 | + | } | |
| 147 | + | assert_eq!(aliasable_name(&"a".repeat(40)), None); | |
| 148 | + | // Still nobody's to register. | |
| 149 | + | assert!(is_reserved_name("g1t") && !is_route_name("g1t")); | |
| 150 | + | } | |
| 117 | 151 | } |
| 1312 | 1312 | MCP clients, the CLI (device flow) and third-party apps all use it. Access | |
| 1313 | 1313 | tokens and SSH keys remain for git itself. | |
| 1314 | 1314 | ||
| 1315 | + | ### Workspace aliases (internal, built 2026-10-07) | |
| 1316 | + | ||
| 1317 | + | `g1t` is the product; `flagon-io` is Flagon, Inc., the organization that | |
| 1318 | + | builds it. So nobody mistakes one for the other, `g1t.sh/g1t` leads to | |
| 1319 | + | `g1t.sh/flagon-io`. That is a workspace alias: a name g1t's staff point at | |
| 1320 | + | a workspace, kept in identity's `workspace_aliases` (migration 0029, which | |
| 1321 | + | seeds `g1t`) by the workspace's id, so it follows renames. It is not a | |
| 1322 | + | customer feature and is not documented for users; staff add and remove | |
| 1323 | + | aliases on sudo's Aliases page, with a reason, in sudo's audit log. We may | |
| 1324 | + | give other companies one for a trading name the same way. | |
| 1325 | + | ||
| 1326 | + | An alias is resolved wherever an old slug is (identity's `resolve_slug`), | |
| 1327 | + | so it costs only the not-found path: site pages 301 to the same page under | |
| 1328 | + | the workspace, the API and MCP run the call again under its slug, package | |
| 1329 | + | registries 301, and git over HTTPS is answered in place | |
| 1330 | + | (`resolve_alias`), because pushes do not follow redirects. An alias is | |
| 1331 | + | never a route, a username or a workspace's slug, and nobody can register | |
| 1332 | + | it while it exists. `@g1t` stays g1t's agent: mentions link to how the | |
| 1333 | + | agent works, never to `/g1t`. | |
| 1334 | + | ||
| 1315 | 1335 | ## Architecture | |
| 1316 | 1336 | ||
| 1317 | 1337 | | Component | Language | Runs on | Responsibility | |
| 272 | 272 | deletedWorkspaces: () => call("admin_deleted_workspaces", {}), | |
| 273 | 273 | restoreWorkspace: (workspaceId, staff) => call("admin_restore_workspace", { workspaceId, staff }), | |
| 274 | 274 | purgeWorkspace: (workspaceId, staff, confirm) => call("admin_purge_workspace", { workspaceId, staff, confirm }), | |
| 275 | + | aliases: () => call("admin_aliases", {}), | |
| 276 | + | setAlias: (alias, workspace, note, staff) => call("admin_set_alias", { alias, workspace, note, staff }), | |
| 277 | + | removeAlias: (alias, reason, staff) => call("admin_remove_alias", { alias, reason, staff }), | |
| 275 | 278 | }; | |
| 276 | 279 | } | |
| 277 | 280 |
| 316 | 316 | * `workspace.deleted`. | |
| 317 | 317 | */ | |
| 318 | 318 | purgeWorkspace(workspaceId: string, staff: string, confirm: string): Promise<Result<boolean>>; | |
| 319 | + | ||
| 320 | + | /** Every workspace alias, by name. */ | |
| 321 | + | aliases(): Promise<WorkspaceAlias[]>; | |
| 322 | + | /** | |
| 323 | + | * Points `alias` at the workspace whose slug is `workspace`. Refused for | |
| 324 | + | * one of the site's routes, anyone's username, a workspace's slug (deleted | |
| 325 | + | * or held after a rename) and an existing alias. `note` says why. | |
| 326 | + | */ | |
| 327 | + | setAlias(alias: string, workspace: string, note: string, staff: string): Promise<Result<WorkspaceAlias>>; | |
| 328 | + | /** Removes an alias; `reason` goes in sudo's audit log. */ | |
| 329 | + | removeAlias(alias: string, reason: string, staff: string): Promise<Result<boolean>>; | |
| 319 | 330 | } | |
| 320 | 331 | ||
| 332 | + | /** | |
| 333 | + | * A name g1t's staff point at a workspace, so its addresses lead there under | |
| 334 | + | * the workspace's own name: `g1t`, the product, leads to `flagon-io`, Flagon, | |
| 335 | + | * Inc. Staff-managed only; it follows the workspace through renames. | |
| 336 | + | */ | |
| 337 | + | export type WorkspaceAlias = { | |
| 338 | + | alias: string; | |
| 339 | + | workspaceId: string; | |
| 340 | + | /** The workspace's slug and name now. */ | |
| 341 | + | workspace: string; | |
| 342 | + | workspaceName: string; | |
| 343 | + | /** Why it exists. */ | |
| 344 | + | note: string; | |
| 345 | + | /** The staff member who set it, or `migration`. */ | |
| 346 | + | createdBy: string; | |
| 347 | + | /** RFC 3339. */ | |
| 348 | + | createdAt: string; | |
| 349 | + | }; | |
| 350 | + | ||
| 321 | 351 | /** Who is asking. Every read and write in every service takes one. */ | |
| 322 | 352 | export type Viewer = User | null; | |
| 323 | 353 | ||
| 549 | 579 | checkWorkspaceRename(actor: User, slug: string, newSlug: string): Promise<Result<boolean>>; | |
| 550 | 580 | /** | |
| 551 | 581 | * The workspace's current slug when `slug` is one it was renamed from | |
| 552 | − | * within `SLUG_HOLD_DAYS`; null otherwise, including for a slug in use. | |
| 582 | + | * within `SLUG_HOLD_DAYS`, or when `slug` is an alias staff set for it | |
| 583 | + | * (`WorkspaceAlias`); null otherwise, including for a slug in use. | |
| 553 | 584 | */ | |
| 554 | 585 | resolveSlug(slug: string): Promise<string | null>; | |
| 555 | 586 | /** |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | export function isValidNamespace(value: string): boolean { | |
| 29 | − | return NAMESPACE.test(value) && !isReservedName(value); | |
| 29 | + | return isNamespaceShaped(value) && !isReservedName(value); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** | |
| 33 | + | * Whether `value` has a namespace's shape, reserved or not: what a | |
| 34 | + | * workspace's old name or an alias staff set (such as `g1t`) can be. | |
| 35 | + | */ | |
| 36 | + | export function isNamespaceShaped(value: string): boolean { | |
| 37 | + | return NAMESPACE.test(value); | |
| 30 | 38 | } | |
| 31 | 39 | ||
| 32 | 40 | export function isValidRepoName(value: string): boolean { |
| 1 | + | -- Workspace aliases: a name g1t's staff point at a workspace, so its | |
| 2 | + | -- addresses lead there under the workspace's own name. Staff-managed only, | |
| 3 | + | -- from sudo; not a feature workspaces can use. An alias is a reserved or | |
| 4 | + | -- unclaimed name, never a person's or a workspace's, and points at the | |
| 5 | + | -- workspace's id, so it follows the workspace through renames. See | |
| 6 | + | -- src/aliases.rs. | |
| 7 | + | CREATE TABLE workspace_aliases ( | |
| 8 | + | alias TEXT PRIMARY KEY, | |
| 9 | + | workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, | |
| 10 | + | created_by TEXT NOT NULL, | |
| 11 | + | created_at TEXT NOT NULL, | |
| 12 | + | note TEXT NOT NULL DEFAULT '' | |
| 13 | + | ); | |
| 14 | + | CREATE INDEX workspace_aliases_by_workspace ON workspace_aliases (workspace_id); | |
| 15 | + | ||
| 16 | + | -- g1t is the product Flagon, Inc. builds; flagon-io is the organization. | |
| 17 | + | -- Nothing is added where flagon-io does not exist (a fresh self-hosted | |
| 18 | + | -- install, a local database). | |
| 19 | + | INSERT OR IGNORE INTO workspace_aliases (alias, workspace_id, created_by, created_at, note) | |
| 20 | + | SELECT 'g1t', id, 'migration', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 'The product''s name, for Flagon, Inc.' | |
| 21 | + | FROM workspaces WHERE slug = 'flagon-io' AND deleted_at IS NULL; |
| 1 | + | //! Workspace aliases: a name g1t's staff point at a workspace, so that its | |
| 2 | + | //! addresses lead to the workspace under its own name. `g1t` is the | |
| 3 | + | //! product Flagon, Inc. builds, and leads to `flagon-io`, the organization | |
| 4 | + | //! (migration 0029), so nobody is confused by the trading name. | |
| 5 | + | //! | |
| 6 | + | //! Staff set and remove them from sudo; there is no way for a workspace to | |
| 7 | + | //! make one. An alias is a reserved or unclaimed name, never a person's or | |
| 8 | + | //! a workspace's, and points at the workspace's id, so it follows the | |
| 9 | + | //! workspace through renames. While it exists nobody can register or | |
| 10 | + | //! rename a workspace to it. | |
| 11 | + | //! | |
| 12 | + | //! An alias is resolved wherever an old slug is (`resolve_slug`): the site | |
| 13 | + | //! and the API redirect or run again under the workspace's slug. Git over | |
| 14 | + | //! HTTPS resolves it in place (`resolve_alias`), as pushes do not follow | |
| 15 | + | //! redirects. | |
| 16 | + | ||
| 17 | + | use g1t_contracts::identity::*; | |
| 18 | + | use g1t_contracts::time::rfc3339; | |
| 19 | + | use g1t_contracts::{FailureCode, Outcome, aliasable_name}; | |
| 20 | + | use g1t_kit::now_ms; | |
| 21 | + | use serde::Deserialize; | |
| 22 | + | use worker::Result; | |
| 23 | + | ||
| 24 | + | use crate::Identity; | |
| 25 | + | ||
| 26 | + | /// The longest note or reason staff may give. | |
| 27 | + | pub const MAX_NOTE_LENGTH: usize = 500; | |
| 28 | + | ||
| 29 | + | /// Everything about a wanted alias that decides whether staff may set it, | |
| 30 | + | /// as read from the database. | |
| 31 | + | #[derive(Debug, Default)] | |
| 32 | + | pub struct Facts<'a> { | |
| 33 | + | /// The workspace it would lead to, if there is one by that slug: its id. | |
| 34 | + | pub target: Option<&'a str>, | |
| 35 | + | /// A person has the name as their username. | |
| 36 | + | pub username: bool, | |
| 37 | + | /// A workspace has it as its slug, deleted or not. | |
| 38 | + | pub workspace: bool, | |
| 39 | + | /// A renamed workspace's old slug still held: the workspace it is held for. | |
| 40 | + | pub held_for: Option<&'a str>, | |
| 41 | + | /// A purged workspace had it; it is never given to anyone else. | |
| 42 | + | pub purged: bool, | |
| 43 | + | /// It is already an alias: of which workspace's slug. | |
| 44 | + | pub alias_of: Option<&'a str>, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// The alias and note to store, or why not, in words for staff. | |
| 48 | + | pub fn check(alias: &str, note: &str, facts: &Facts) -> std::result::Result<(String, String), (FailureCode, String)> { | |
| 49 | + | let refuse = |code, message: String| Err((code, message)); | |
| 50 | + | let Some(alias) = aliasable_name(alias) else { | |
| 51 | + | return refuse( | |
| 52 | + | FailureCode::Invalid, | |
| 53 | + | "An alias uses lowercase letters, digits and single hyphens, up to 39 characters, and cannot be one of the site's routes.".into(), | |
| 54 | + | ); | |
| 55 | + | }; | |
| 56 | + | let note = note.trim(); | |
| 57 | + | if note.is_empty() { | |
| 58 | + | return refuse(FailureCode::Invalid, "Say why the alias exists.".into()); | |
| 59 | + | } | |
| 60 | + | if note.chars().count() > MAX_NOTE_LENGTH { | |
| 61 | + | return refuse(FailureCode::Invalid, format!("Keep the note to {MAX_NOTE_LENGTH} characters.")); | |
| 62 | + | } | |
| 63 | + | let Some(target) = facts.target else { | |
| 64 | + | return refuse(FailureCode::NotFound, "There is no workspace with that slug.".into()); | |
| 65 | + | }; | |
| 66 | + | if let Some(slug) = facts.alias_of { | |
| 67 | + | return refuse(FailureCode::Conflict, format!("{alias} is already an alias of {slug}.")); | |
| 68 | + | } | |
| 69 | + | if facts.username { | |
| 70 | + | return refuse(FailureCode::Conflict, format!("{alias} is someone's username.")); | |
| 71 | + | } | |
| 72 | + | if facts.workspace { | |
| 73 | + | return refuse(FailureCode::Conflict, format!("{alias} is a workspace's slug.")); | |
| 74 | + | } | |
| 75 | + | if facts.purged { | |
| 76 | + | return refuse(FailureCode::Conflict, format!("{alias} belonged to a deleted workspace.")); | |
| 77 | + | } | |
| 78 | + | // A workspace's own old slug can become its alias for good. | |
| 79 | + | if facts.held_for.is_some_and(|holder| holder != target) { | |
| 80 | + | return refuse(FailureCode::Conflict, format!("{alias} is held for a renamed workspace.")); | |
| 81 | + | } | |
| 82 | + | Ok((alias, note.to_owned())) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Where a first path segment leads, in the order `resolve_slug` asks: a | |
| 86 | + | /// workspace that has it leads nowhere else; then an alias, which is for | |
| 87 | + | /// good; then a renamed workspace's old slug, while it is held. | |
| 88 | + | pub fn resolve(in_use: bool, alias: Option<String>, renamed: impl FnOnce() -> Option<String>) -> Option<String> { | |
| 89 | + | if in_use { | |
| 90 | + | return None; | |
| 91 | + | } | |
| 92 | + | alias.or_else(renamed) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[derive(Deserialize)] | |
| 96 | + | struct AliasRow { | |
| 97 | + | alias: String, | |
| 98 | + | workspace_id: String, | |
| 99 | + | slug: String, | |
| 100 | + | name: String, | |
| 101 | + | note: String, | |
| 102 | + | created_by: String, | |
| 103 | + | created_at: String, | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | impl From<AliasRow> for WorkspaceAlias { | |
| 107 | + | fn from(row: AliasRow) -> Self { | |
| 108 | + | WorkspaceAlias { | |
| 109 | + | alias: row.alias, | |
| 110 | + | workspace_id: row.workspace_id, | |
| 111 | + | workspace: row.slug, | |
| 112 | + | workspace_name: row.name, | |
| 113 | + | note: row.note, | |
| 114 | + | created_by: row.created_by, | |
| 115 | + | created_at: row.created_at, | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// Aliases with their workspace as it is now. Never a deleted one's. | |
| 121 | + | const ALIAS_ROWS: &str = "SELECT a.alias, a.workspace_id, w.slug, w.name, a.note, a.created_by, a.created_at | |
| 122 | + | FROM workspace_aliases a JOIN workspaces w ON w.id = a.workspace_id AND w.deleted_at IS NULL"; | |
| 123 | + | ||
| 124 | + | #[derive(Deserialize)] | |
| 125 | + | struct Id { | |
| 126 | + | id: String, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | impl Identity { | |
| 130 | + | async fn alias_row(&self, alias: &str) -> Result<Option<AliasRow>> { | |
| 131 | + | self.db | |
| 132 | + | .prepare(format!("{ALIAS_ROWS} WHERE a.alias = ?")) | |
| 133 | + | .bind(&[alias.into()])? | |
| 134 | + | .first::<AliasRow>(None) | |
| 135 | + | .await | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// `resolve_alias`: the slug now of the workspace `slug` is an alias of. | |
| 139 | + | pub async fn resolve_alias(&self, a: SlugArgs) -> Result<Option<String>> { | |
| 140 | + | let slug = a.slug.trim().to_lowercase(); | |
| 141 | + | Ok(self.alias_row(&slug).await?.map(|row| row.slug)) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// Whether `slug` is an alias, of a workspace deleted or not, so nobody | |
| 145 | + | /// may register or rename a workspace to it. | |
| 146 | + | pub async fn is_alias(&self, slug: &str) -> Result<bool> { | |
| 147 | + | Ok(self | |
| 148 | + | .db | |
| 149 | + | .prepare("SELECT 1 AS held FROM workspace_aliases WHERE alias = ?") | |
| 150 | + | .bind(&[slug.trim().to_lowercase().into()])? | |
| 151 | + | .first::<serde_json::Value>(None) | |
| 152 | + | .await? | |
| 153 | + | .is_some()) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// `admin_aliases`: every alias, by name. Staff only. | |
| 157 | + | pub async fn admin_aliases(&self) -> Result<Vec<WorkspaceAlias>> { | |
| 158 | + | Ok(self | |
| 159 | + | .db | |
| 160 | + | .prepare(format!("{ALIAS_ROWS} ORDER BY a.alias")) | |
| 161 | + | .all() | |
| 162 | + | .await? | |
| 163 | + | .results::<AliasRow>()? | |
| 164 | + | .into_iter() | |
| 165 | + | .map(WorkspaceAlias::from) | |
| 166 | + | .collect()) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// `admin_set_alias`: staff only. Recorded in sudo's audit log. | |
| 170 | + | pub async fn admin_set_alias(&self, a: AdminSetAliasArgs) -> Result<Outcome<WorkspaceAlias>> { | |
| 171 | + | let staff = a.staff.trim(); | |
| 172 | + | if staff.is_empty() { | |
| 173 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is setting it.")); | |
| 174 | + | } | |
| 175 | + | let alias = a.alias.trim().to_lowercase(); | |
| 176 | + | let workspace = a.workspace.trim().to_lowercase(); | |
| 177 | + | let target = self | |
| 178 | + | .db | |
| 179 | + | .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL") | |
| 180 | + | .bind(&[workspace.as_str().into()])? | |
| 181 | + | .first::<Id>(None) | |
| 182 | + | .await?; | |
| 183 | + | let username = self | |
| 184 | + | .db | |
| 185 | + | .prepare("SELECT 1 AS taken FROM users WHERE username = ?") | |
| 186 | + | .bind(&[alias.as_str().into()])? | |
| 187 | + | .first::<serde_json::Value>(None) | |
| 188 | + | .await? | |
| 189 | + | .is_some(); | |
| 190 | + | #[derive(Deserialize)] | |
| 191 | + | struct Held { | |
| 192 | + | workspace_id: String, | |
| 193 | + | created_at: String, | |
| 194 | + | } | |
| 195 | + | let held = self | |
| 196 | + | .db | |
| 197 | + | .prepare("SELECT workspace_id, created_at FROM workspace_redirects WHERE old_slug = ?") | |
| 198 | + | .bind(&[alias.as_str().into()])? | |
| 199 | + | .first::<Held>(None) | |
| 200 | + | .await? | |
| 201 | + | .filter(|row| row.created_at >= crate::rename::hold_cutoff(now_ms())); | |
| 202 | + | let existing = self.alias_row(&alias).await?; | |
| 203 | + | // An alias of a deleted workspace is not listed, but still holds. | |
| 204 | + | let alias_of = match &existing { | |
| 205 | + | Some(row) => Some(row.slug.clone()), | |
| 206 | + | None => self.is_alias(&alias).await?.then(|| "a deleted workspace".to_owned()), | |
| 207 | + | }; | |
| 208 | + | let facts = Facts { | |
| 209 | + | target: target.as_ref().map(|row| row.id.as_str()), | |
| 210 | + | username, | |
| 211 | + | workspace: self.slug_in_use(&alias).await?, | |
| 212 | + | held_for: held.as_ref().map(|row| row.workspace_id.as_str()), | |
| 213 | + | purged: self.slug_deleted(&alias).await?, | |
| 214 | + | alias_of: alias_of.as_deref(), | |
| 215 | + | }; | |
| 216 | + | let (alias, note) = match check(&alias, &a.note, &facts) { | |
| 217 | + | Ok(checked) => checked, | |
| 218 | + | Err((code, message)) => return Ok(Outcome::fail(code, message)), | |
| 219 | + | }; | |
| 220 | + | let Some(target) = target else { | |
| 221 | + | return Ok(Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug.")); | |
| 222 | + | }; | |
| 223 | + | self.db | |
| 224 | + | .batch(vec![ | |
| 225 | + | // Its own old slug, made its alias: the redirect gives way. | |
| 226 | + | self.db | |
| 227 | + | .prepare("DELETE FROM workspace_redirects WHERE old_slug = ? AND workspace_id = ?") | |
| 228 | + | .bind(&[alias.as_str().into(), target.id.as_str().into()])?, | |
| 229 | + | self.db | |
| 230 | + | .prepare( | |
| 231 | + | "INSERT INTO workspace_aliases (alias, workspace_id, created_by, created_at, note) | |
| 232 | + | VALUES (?, ?, ?, ?, ?)", | |
| 233 | + | ) | |
| 234 | + | .bind(&[ | |
| 235 | + | alias.as_str().into(), | |
| 236 | + | target.id.as_str().into(), | |
| 237 | + | staff.into(), | |
| 238 | + | rfc3339(now_ms()).into(), | |
| 239 | + | note.as_str().into(), | |
| 240 | + | ])?, | |
| 241 | + | ]) | |
| 242 | + | .await?; | |
| 243 | + | self.record_for_staff(&workspace, "alias_added", &format!("Alias {alias} leads to {workspace}: {note}"), staff) | |
| 244 | + | .await; | |
| 245 | + | Ok(match self.alias_row(&alias).await? { | |
| 246 | + | Some(row) => Outcome::Ok(row.into()), | |
| 247 | + | None => Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug."), | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// `admin_remove_alias`: staff only. Recorded in sudo's audit log. | |
| 252 | + | pub async fn admin_remove_alias(&self, a: AdminRemoveAliasArgs) -> Result<Outcome<bool>> { | |
| 253 | + | let staff = a.staff.trim(); | |
| 254 | + | if staff.is_empty() { | |
| 255 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is removing it.")); | |
| 256 | + | } | |
| 257 | + | let reason = a.reason.trim(); | |
| 258 | + | if reason.is_empty() { | |
| 259 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Say why the alias is being removed.")); | |
| 260 | + | } | |
| 261 | + | if reason.chars().count() > MAX_NOTE_LENGTH { | |
| 262 | + | return Ok(Outcome::fail( | |
| 263 | + | FailureCode::Invalid, | |
| 264 | + | format!("Keep the reason to {MAX_NOTE_LENGTH} characters."), | |
| 265 | + | )); | |
| 266 | + | } | |
| 267 | + | let alias = a.alias.trim().to_lowercase(); | |
| 268 | + | let Some(row) = self.alias_row(&alias).await? else { | |
| 269 | + | return Ok(Outcome::fail(FailureCode::NotFound, "There is no alias by that name.")); | |
| 270 | + | }; | |
| 271 | + | self.db | |
| 272 | + | .prepare("DELETE FROM workspace_aliases WHERE alias = ?") | |
| 273 | + | .bind(&[alias.as_str().into()])? | |
| 274 | + | .run() | |
| 275 | + | .await?; | |
| 276 | + | self.record_for_staff( | |
| 277 | + | &row.slug, | |
| 278 | + | "alias_removed", | |
| 279 | + | &format!("Alias {alias} no longer leads to {}: {reason}", row.slug), | |
| 280 | + | staff, | |
| 281 | + | ) | |
| 282 | + | .await; | |
| 283 | + | Ok(Outcome::Ok(true)) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[cfg(test)] | |
| 288 | + | mod tests { | |
| 289 | + | use super::*; | |
| 290 | + | use std::collections::HashMap; | |
| 291 | + | ||
| 292 | + | fn facts<'a>() -> Facts<'a> { | |
| 293 | + | Facts { | |
| 294 | + | target: Some("wsp_flagon"), | |
| 295 | + | ..Facts::default() | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | fn refused(alias: &str, facts: &Facts) -> FailureCode { | |
| 300 | + | check(alias, "The product's name", facts).unwrap_err().0 | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[test] | |
| 304 | + | fn a_reserved_or_unclaimed_name_can_be_an_alias() { | |
| 305 | + | assert_eq!( | |
| 306 | + | check(" G1T ", " The product's name, for Flagon, Inc. ", &facts()).unwrap(), | |
| 307 | + | ("g1t".to_owned(), "The product's name, for Flagon, Inc.".to_owned()) | |
| 308 | + | ); | |
| 309 | + | assert!(check("flagon", "Short name", &facts()).is_ok()); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn routes_and_malformed_names_are_never_aliases() { | |
| 314 | + | for bad in ["settings", "api", "login", "-g1t", "g1t-", "g--1t", "g1t_inc", "", "a.b"] { | |
| 315 | + | assert_eq!(refused(bad, &facts()), FailureCode::Invalid, "{bad}"); | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[test] | |
| 320 | + | fn a_reason_is_required_and_bounded() { | |
| 321 | + | assert_eq!(check("g1t", " ", &facts()).unwrap_err().0, FailureCode::Invalid); | |
| 322 | + | let long = "x".repeat(MAX_NOTE_LENGTH + 1); | |
| 323 | + | assert_eq!(check("g1t", &long, &facts()).unwrap_err().0, FailureCode::Invalid); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[test] | |
| 327 | + | fn a_persons_or_workspaces_name_is_never_an_alias() { | |
| 328 | + | let missing = Facts { target: None, ..facts() }; | |
| 329 | + | assert_eq!(refused("g1t", &missing), FailureCode::NotFound); | |
| 330 | + | let person = Facts { username: true, ..facts() }; | |
| 331 | + | assert_eq!(refused("ana", &person), FailureCode::Conflict); | |
| 332 | + | let workspace = Facts { workspace: true, ..facts() }; | |
| 333 | + | assert_eq!(refused("acme", &workspace), FailureCode::Conflict); | |
| 334 | + | let purged = Facts { purged: true, ..facts() }; | |
| 335 | + | assert_eq!(refused("initech", &purged), FailureCode::Conflict); | |
| 336 | + | let aliased = Facts { | |
| 337 | + | alias_of: Some("globex"), | |
| 338 | + | ..facts() | |
| 339 | + | }; | |
| 340 | + | let (code, message) = check("g1t", "x", &aliased).unwrap_err(); | |
| 341 | + | assert_eq!(code, FailureCode::Conflict); | |
| 342 | + | assert_eq!(message, "g1t is already an alias of globex."); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn only_its_own_old_slug_can_become_a_workspaces_alias() { | |
| 347 | + | let others = Facts { | |
| 348 | + | held_for: Some("wsp_other"), | |
| 349 | + | ..facts() | |
| 350 | + | }; | |
| 351 | + | assert_eq!(refused("acme", &others), FailureCode::Conflict); | |
| 352 | + | let own = Facts { | |
| 353 | + | held_for: Some("wsp_flagon"), | |
| 354 | + | ..facts() | |
| 355 | + | }; | |
| 356 | + | assert!(check("flagon", "Its old name, for good", &own).is_ok()); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn a_workspace_in_use_is_never_resolved_elsewhere() { | |
| 361 | + | let alias = || Some("flagon-io".to_owned()); | |
| 362 | + | assert_eq!(resolve(true, alias(), || Some("x".into())), None); | |
| 363 | + | assert_eq!(resolve(false, alias(), || Some("x".into())).as_deref(), Some("flagon-io")); | |
| 364 | + | assert_eq!(resolve(false, None, || Some("acme-inc".into())).as_deref(), Some("acme-inc")); | |
| 365 | + | assert_eq!(resolve(false, None, || None), None); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /// Aliases point at ids, as the table does; renames change the slug. | |
| 369 | + | #[test] | |
| 370 | + | fn an_alias_follows_its_workspace_through_renames() { | |
| 371 | + | let mut slugs: HashMap<&str, &str> = HashMap::from([("wsp_flagon", "flagon-io")]); | |
| 372 | + | let aliases: HashMap<&str, &str> = HashMap::from([("g1t", "wsp_flagon")]); | |
| 373 | + | let lookup = |slugs: &HashMap<&str, &str>, alias: &str| { | |
| 374 | + | aliases.get(alias).and_then(|id| slugs.get(id)).map(|slug| (*slug).to_owned()) | |
| 375 | + | }; | |
| 376 | + | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-io")); | |
| 377 | + | slugs.insert("wsp_flagon", "flagon"); | |
| 378 | + | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon")); | |
| 379 | + | slugs.insert("wsp_flagon", "flagon-inc"); | |
| 380 | + | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-inc")); | |
| 381 | + | assert_eq!(lookup(&slugs, "acme"), None); | |
| 382 | + | } | |
| 383 | + | } |
| 608 | 608 | self.db | |
| 609 | 609 | .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?") | |
| 610 | 610 | .bind(&[id.into()])?, | |
| 611 | + | // Aliases staff pointed at it lead nowhere now (aliases.rs). | |
| 612 | + | self.db | |
| 613 | + | .prepare("DELETE FROM workspace_aliases WHERE workspace_id = ?") | |
| 614 | + | .bind(&[id.into()])?, | |
| 611 | 615 | // Only while it is still deleted: a restore a moment ago wins. | |
| 612 | 616 | self.db | |
| 613 | 617 | .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL") | |
| 627 | 631 | } | |
| 628 | 632 | ||
| 629 | 633 | /// An entry in the workspace's audit log, which outlives it. | |
| 630 | − | async fn record_on_workspace( | |
| 634 | + | pub(crate) async fn record_on_workspace( | |
| 631 | 635 | &self, | |
| 632 | 636 | slug: &str, | |
| 633 | 637 | actor: AuditActor, | |
| 668 | 672 | ||
| 669 | 673 | /// A line in sudo's audit log (billing keeps it), naming the staff | |
| 670 | 674 | /// member. | |
| 671 | − | async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) { | |
| 675 | + | pub(crate) async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) { | |
| 672 | 676 | let Ok(billing) = self.env.service("BILLING") else { | |
| 673 | 677 | return; | |
| 674 | 678 | }; |
| 5 | 5 | ||
| 6 | 6 | mod access; | |
| 7 | 7 | mod admin; | |
| 8 | + | mod aliases; | |
| 8 | 9 | mod avatars; | |
| 9 | 10 | mod crypto; | |
| 10 | 11 | mod deletion; | |
| 736 | 737 | "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?), | |
| 737 | 738 | "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?), | |
| 738 | 739 | "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?), | |
| 740 | + | "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?), | |
| 739 | 741 | "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?), | |
| 740 | 742 | "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?), | |
| 741 | 743 | "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?), | |
| 891 | 893 | "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?), | |
| 892 | 894 | "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?), | |
| 893 | 895 | "admin_purge_workspace" => reply(&identity.admin_purge_workspace(args(body)?).await?), | |
| 896 | + | // Workspace aliases, set by staff only; see aliases.rs. | |
| 897 | + | "admin_aliases" => reply(&identity.admin_aliases().await?), | |
| 898 | + | "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?), | |
| 899 | + | "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?), | |
| 894 | 900 | _ => Response::error("Unknown method", 404), | |
| 895 | 901 | }; | |
| 896 | 902 | served.finish(answered) |
| 56 | 56 | pub last_renamed_at: Option<&'a str>, | |
| 57 | 57 | /// A deleted workspace had `wanted`; it is never given to another. | |
| 58 | 58 | pub deleted: bool, | |
| 59 | + | /// Staff made `wanted` an alias (aliases.rs); it stays theirs. | |
| 60 | + | pub aliased: bool, | |
| 59 | 61 | pub now_ms: u64, | |
| 60 | 62 | } | |
| 61 | 63 | ||
| 81 | 83 | ); | |
| 82 | 84 | } | |
| 83 | 85 | } | |
| 84 | − | if facts.someone_elses_username || facts.another_workspace || facts.deleted { | |
| 86 | + | if facts.someone_elses_username || facts.another_workspace || facts.deleted || facts.aliased { | |
| 85 | 87 | return refuse(FailureCode::Conflict, TAKEN); | |
| 86 | 88 | } | |
| 87 | 89 | if let Some((holder, created_at)) = facts.redirect | |
| 131 | 133 | } | |
| 132 | 134 | ||
| 133 | 135 | /// Whether `slug` is an old slug still reserved for the workspace that | |
| 134 | − | /// had it, so nobody else may register or create it. | |
| 136 | + | /// had it, or an alias staff set, so nobody else may register or create | |
| 137 | + | /// it. | |
| 135 | 138 | pub async fn slug_held(&self, slug: &str) -> Result<bool> { | |
| 136 | − | Ok(resolve(self.redirect(slug).await?, now_ms()).is_some()) | |
| 139 | + | Ok(resolve(self.redirect(slug).await?, now_ms()).is_some() || self.is_alias(slug).await?) | |
| 137 | 140 | } | |
| 138 | 141 | ||
| 139 | − | /// `resolve_slug`: the current slug for an old one still redirecting. | |
| 142 | + | /// `resolve_slug`: the current slug for an old one still redirecting, | |
| 143 | + | /// or for an alias (aliases.rs). | |
| 140 | 144 | pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> { | |
| 141 | 145 | let slug = a.slug.trim().to_lowercase(); | |
| 142 | − | if self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some() { | |
| 146 | + | let in_use = self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some(); | |
| 147 | + | if in_use { | |
| 143 | 148 | return Ok(None); | |
| 144 | 149 | } | |
| 145 | − | Ok(resolve(self.redirect(&slug).await?, now_ms())) | |
| 150 | + | let alias = self.resolve_alias(SlugArgs { slug: slug.clone() }).await?; | |
| 151 | + | let redirect = match alias { | |
| 152 | + | Some(_) => None, | |
| 153 | + | None => self.redirect(&slug).await?, | |
| 154 | + | }; | |
| 155 | + | Ok(crate::aliases::resolve(in_use, alias, || resolve(redirect, now_ms()))) | |
| 146 | 156 | } | |
| 147 | 157 | ||
| 148 | 158 | /// Checks a rename, returning the workspace's id when it is allowed. | |
| 203 | 213 | .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())), | |
| 204 | 214 | last_renamed_at: last_renamed_at.as_deref(), | |
| 205 | 215 | deleted: self.slug_deleted(&wanted).await?, | |
| 216 | + | aliased: self.is_alias(&wanted).await?, | |
| 206 | 217 | now_ms: now_ms(), | |
| 207 | 218 | }; | |
| 208 | 219 | Ok(match check(&facts) { | |
| 364 | 375 | ..facts("acme", "initech") | |
| 365 | 376 | }; | |
| 366 | 377 | assert_eq!(refused(&deleted), FailureCode::Conflict); | |
| 378 | + | let aliased = Facts { | |
| 379 | + | aliased: true, | |
| 380 | + | ..facts("acme", "flagon") | |
| 381 | + | }; | |
| 382 | + | assert_eq!(refused(&aliased), FailureCode::Conflict); | |
| 367 | 383 | } | |
| 368 | 384 | ||
| 369 | 385 | #[test] |
| 2 | 2 | // service answers anonymous git requests with `wrangler dev` (repos.jsonc). | |
| 3 | 3 | // | |
| 4 | 4 | // - Identity knows nobody: credentials name no one, and no workspace was | |
| 5 | − | // renamed. Public repositories can be cloned without signing in. | |
| 5 | + | // renamed or aliased. Public repositories can be cloned without signing in. | |
| 6 | 6 | // - Events takes every event and audit entry and logs them. | |
| 7 | 7 | // - Security has allowed no secrets; billing says every workspace is free. | |
| 8 | 8 | ||
| 14 | 14 | switch (method) { | |
| 15 | 15 | case "user_for_git_credentials": | |
| 16 | 16 | case "resolve_slug": | |
| 17 | + | case "resolve_alias": | |
| 17 | 18 | return json(null); | |
| 18 | 19 | case "is_free": | |
| 19 | 20 | case "plan": |
| 32 | 32 | pub service: GitService, | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | impl GitRequest { | |
| 36 | + | /// The same request for the repository of the same name under | |
| 37 | + | /// `namespace`: where a workspace alias leads. | |
| 38 | + | pub fn under(&self, namespace: &str) -> GitRequest { | |
| 39 | + | GitRequest { | |
| 40 | + | path: RepoPath { | |
| 41 | + | namespace: namespace.to_owned(), | |
| 42 | + | name: self.path.name.clone(), | |
| 43 | + | }, | |
| 44 | + | endpoint: self.endpoint, | |
| 45 | + | service: self.service, | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 35 | 50 | /// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the | |
| 36 | 51 | /// request is not git's. | |
| 37 | 52 | pub fn parse(url: &Url) -> Option<GitRequest> { | |
| 216 | 231 | Ok(current.and_then(|slug| with_namespace(url, &slug))) | |
| 217 | 232 | } | |
| 218 | 233 | ||
| 234 | + | /// The request under the workspace its first segment is an alias of | |
| 235 | + | /// (identity's aliases.rs: `g1t` for `flagon-io`), if it is one. Answered | |
| 236 | + | /// in place rather than redirected: a push does not follow a redirect. | |
| 237 | + | pub async fn aliased(git: &GitRequest, identity: &Fetcher) -> Result<Option<GitRequest>> { | |
| 238 | + | let slug: Option<String> = g1t_kit::call( | |
| 239 | + | identity, | |
| 240 | + | "resolve_alias", | |
| 241 | + | &g1t_contracts::identity::SlugArgs { | |
| 242 | + | slug: git.path.namespace.clone(), | |
| 243 | + | }, | |
| 244 | + | ) | |
| 245 | + | .await?; | |
| 246 | + | Ok(slug.map(|slug| git.under(&slug))) | |
| 247 | + | } | |
| 248 | + | ||
| 219 | 249 | /// `url` with its repository, the first two path segments, replaced by | |
| 220 | 250 | /// `to`: where a request for a transferred repository's old path goes. | |
| 221 | 251 | /// Keeps whether the old address ended in `.git`. | |
| 1050 | 1080 | ||
| 1051 | 1081 | #[cfg(test)] | |
| 1052 | 1082 | mod tests { | |
| 1053 | − | use super::{Acknowledged, Pushed, RepoPath, Url, ZERO_ID, acknowledged, framed, negotiating, pack_bytes, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace}; | |
| 1083 | + | use super::{Acknowledged, GitService, Pushed, RepoPath, Url, ZERO_ID, acknowledged, framed, negotiating, pack_bytes, parse, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace}; | |
| 1054 | 1084 | ||
| 1055 | 1085 | #[test] | |
| 1056 | 1086 | fn server_timing_names_each_step_and_the_total() { | |
| 1159 | 1189 | } | |
| 1160 | 1190 | ||
| 1161 | 1191 | #[test] | |
| 1192 | + | fn an_alias_is_answered_as_its_workspaces_repository() { | |
| 1193 | + | for (address, service) in [ | |
| 1194 | + | ("https://g1t.sh/g1t/g1t.git/info/refs?service=git-upload-pack", GitService::UploadPack), | |
| 1195 | + | ("https://g1t.sh/g1t/g1t.git/git-receive-pack", GitService::ReceivePack), | |
| 1196 | + | ("https://g1t.sh/g1t/g1t/git-upload-pack", GitService::UploadPack), | |
| 1197 | + | ] { | |
| 1198 | + | let git = parse(&Url::parse(address).unwrap()).unwrap(); | |
| 1199 | + | assert_eq!(git.path.namespace, "g1t", "{address}"); | |
| 1200 | + | let canonical = git.under("flagon-io"); | |
| 1201 | + | assert_eq!( | |
| 1202 | + | canonical.path, | |
| 1203 | + | RepoPath { | |
| 1204 | + | namespace: "flagon-io".into(), | |
| 1205 | + | name: "g1t".into(), | |
| 1206 | + | }, | |
| 1207 | + | "{address}" | |
| 1208 | + | ); | |
| 1209 | + | assert_eq!(canonical.service, service); | |
| 1210 | + | assert_eq!(canonical.endpoint, git.endpoint); | |
| 1211 | + | } | |
| 1212 | + | } | |
| 1213 | + | ||
| 1214 | + | #[test] | |
| 1162 | 1215 | fn a_renamed_workspace_keeps_the_rest_of_the_address() { | |
| 1163 | 1216 | let url = worker::Url::parse( | |
| 1164 | 1217 | "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack", |
| 1568 | 1568 | }; | |
| 1569 | 1569 | let (found, viewer) = | |
| 1570 | 1570 | futures_util::future::join(lookup, git_http::viewer(&request, &identity)).await; | |
| 1571 | − | let found = found?; | |
| 1571 | + | let mut found = found?; | |
| 1572 | 1572 | timing.mark("repo"); | |
| 1573 | + | // A workspace alias staff set (identity's aliases.rs: `g1t` for | |
| 1574 | + | // `flagon-io`) is answered in place, as the repository under the | |
| 1575 | + | // workspace's slug: pushes and some clients do not follow | |
| 1576 | + | // redirects. Everything after this sees only the workspace's slug. | |
| 1577 | + | let aliased = match found { | |
| 1578 | + | Some(_) => None, | |
| 1579 | + | None => git_http::aliased(git, &identity).await?, | |
| 1580 | + | }; | |
| 1581 | + | if let Some(aliased) = &aliased { | |
| 1582 | + | found = if write { | |
| 1583 | + | self.registry.by_path(&aliased.path).await? | |
| 1584 | + | } else { | |
| 1585 | + | self.registry.by_path_recent(&aliased.path).await? | |
| 1586 | + | }; | |
| 1587 | + | timing.mark("alias"); | |
| 1588 | + | } | |
| 1589 | + | let git = aliased.as_ref().unwrap_or(git); | |
| 1573 | 1590 | if found.is_none() { | |
| 1574 | 1591 | // A workspace that was renamed: git follows a redirect when it | |
| 1575 | 1592 | // first asks for refs, and uses the new address from then on. |