Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

35 files+1120−610/35 viewed
+4−4
29822982 }
29832983
29842984 /// 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.
29892989 pub async fn run(
29902990 self,
29912991 services: &Services,
+17−1
55 //! repository (`owner/name`) or a workspace by a slug the workspace has
66 //! since been renamed from is run again under the slug it has now, so
77 //! 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.
911
1012 use g1t_contracts::identity::SlugArgs;
1113 use serde_json::Value;
107109 }
108110
109111 #[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]
110126 fn rewrites_only_the_old_slug() {
111127 let input = json!({ "repo": "Acme/rocket", "workspace": "acme", "title": "acme/x" });
112128 assert_eq!(
+23−0
7070 protected workspace. Both go in the workspace's audit log, as g1t, and in
7171 sudo's (`workspace_restored`, `workspace_purged`), naming the staff
7272 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`.
7396 - **Enterprises**: customers that pay for several workspaces with one
7497 bill, one limit and one set of terms. Each has its workspaces (add or
7598 remove them), combined usage, terms, credits, ledger and audit log, and
+2−0
3131 Server,
3232 ShieldAlert,
3333 ShieldCheck,
34+ Signpost,
3435 Siren,
3536 Tags,
3637 Ticket,
7273 overages: TrendingUp,
7374 velocity: Activity,
7475 invites: Ticket,
76+ aliases: Signpost,
7577 customers: UsersRound,
7678 spend: Gauge,
7779 revenue: CircleDollarSign,
+32−0
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+});
+36−0
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+}
+3−0
110110 // From identity: deleted workspaces staff restored or purged.
111111 workspace_restored: "Workspace restored",
112112 workspace_purged: "Workspace purged",
113+ // From identity: workspace aliases staff set or removed.
114+ alias_added: "Alias added",
115+ alias_removed: "Alias removed",
113116 // From the status page (apps/status), merged in by the Audit log page.
114117 incident_declared: "Incident declared",
115118 incident_detected: "Incident detected",
+1−1
4040
4141 test("the built pages are not marked soon", () => {
4242 const built = navItems().filter((item) => !item.soon).map((item) => item.to);
43− assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]);
43+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]);
4444 });
4545
4646 test("every soon page says what it will do, why, and what it will have", () => {
+7−0
3232 | "overages"
3333 | "velocity"
3434 | "invites"
35+ | "aliases"
3536 // Sections
3637 | "customers"
3738 | "spend"
103104 count: "waitlist",
104105 },
105106 {
107+ label: "Aliases",
108+ to: "/aliases",
109+ icon: "aliases",
110+ about: "Names that lead to a workspace, such as g1t to flagon-io. Staff set them; customers cannot.",
111+ },
112+ {
106113 label: "People",
107114 to: "/people",
108115 icon: "people",
+1−0
1010 route("users/:username", "routes/user.tsx"),
1111 route("enterprises", "routes/enterprises.tsx"),
1212 route("invites", "routes/invites.tsx"),
13+ route("aliases", "routes/aliases.tsx"),
1314 route("enterprises/new", "routes/new-enterprise.tsx"),
1415 route("enterprises/:id", "routes/enterprise.tsx"),
1516 route("reach-out", "routes/reach-out.tsx"),
+165−0
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+}
+11−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { rehypeReferences } from "./markdown-plugins.ts";
4+import { G1T_MENTION_HREF, rehypeReferences } from "./markdown-plugins.ts";
55
66 type Node = { type: string; value?: string; tagName?: string; properties?: Record<string, unknown>; children?: Node[] };
77
1919 assert.deepEqual(links("me@example.com"), []);
2020 });
2121
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+
2232 test("@workspace/team links to the team", () => {
2333 assert.deepEqual(links("cc @acme/backend, @ana"), [
2434 ["/acme/-/teams/backend", "@acme/backend"],
+12−3
6060 };
6161 }
6262
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+
6370 /** `owner/repo#12`, `#12`, `@workspace/team`, `@name` and commit hashes, in one pass. */
6471 const REFERENCE =
6572 /(?<![\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;
7683 /**
7784 * Links what a forge's text refers to: `#12` to an issue or pull request
7885 * 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.
8189 */
8290 export function rehypeReferences(options: { repo?: MarkdownRepo }) {
8391 const { repo } = options;
106114 } else if (workspace && team) {
107115 replacement = link(`/${workspace.toLowerCase()}/-/teams/${team.toLowerCase()}`, whole, "team");
108116 } 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");
110119 } else if (hash && repo && /\d/.test(hash) && /[a-f]/i.test(hash)) {
111120 replacement = link(`/${repo.namespace}/${repo.name}/commit/${hash}`, hash.slice(0, 7), "commit");
112121 }
+24−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { servicePath } from "./registry-paths.ts";
4+import { registryWorkspace, servicePath } from "./registry-paths.ts";
55
66 test("the container registry's paths go to the packages service", () => {
77 for (const path of [
119119 assert.equal(servicePath(path), null, path);
120120 }
121121 });
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+});
+24−0
2929 if (GIT_PATH.test(pathname)) return "git";
3030 return null;
3131 }
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+}
+12−11
11 import { redirect } from "react-router";
22
3−import { isValidNamespace } from "@g1t/contracts";
3+import { isNamespaceShaped } from "@g1t/contracts";
44
55 import type { Viewer } from "@g1t/contracts";
66
77 import { identity, repos } from "./services.server";
8+import { underWorkspace } from "./workspace-nav";
89
910 /**
1011 * 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.
1518 */
1619 export async function redirectIfRenamed(request: Request, slug: string): Promise<void> {
1720 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;
2024 let current: string | null = null;
2125 try {
2226 current = await identity.resolveSlug(old);
2630 }
2731 if (!current || current === old) return;
2832 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);
3334 }
3435
3536 /**
+16−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { SETTINGS_PAGES, WORKSPACE_PAGES, pagePath, sidebarCurrent, workspacePage, workspaceRedirect } from "./workspace-nav.ts";
4+import { SETTINGS_PAGES, WORKSPACE_PAGES, pagePath, sidebarCurrent, underWorkspace, workspacePage, workspaceRedirect } from "./workspace-nav.ts";
55
66 test("a path is one of the workspace's pages, or none", () => {
77 assert.equal(workspacePage("/acme", "acme"), "overview");
7373 assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=routes%2Fworkspace%2Fmoved-members"), "/acme/-/people");
7474 assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=x&q=ada"), "/acme/-/people?q=ada");
7575 });
76+
77+test("an alias or old name leads to the same page under the workspace", () => {
78+ assert.equal(underWorkspace("/g1t", "", "flagon-io"), "/flagon-io");
79+ assert.equal(underWorkspace("/g1t/g1t/issues", "?q=is%3Aopen", "flagon-io"), "/flagon-io/g1t/issues?q=is%3Aopen");
80+ assert.equal(underWorkspace("/g1t/g1t/blob/main/README.md", "", "flagon-io"), "/flagon-io/g1t/blob/main/README.md");
81+ assert.equal(underWorkspace("/g1t/-/people/", "", "flagon-io"), "/flagon-io/-/people");
82+});
83+
84+test("a click's data request for an alias leads to the page, not its data", () => {
85+ assert.equal(underWorkspace("/g1t.data", "?_routes=routes%2Fworkspace%2Flayout", "flagon-io"), "/flagon-io");
86+ assert.equal(
87+ underWorkspace("/g1t/g1t/pulls.data", "?_routes=routes%2Frepo%2Flayout&state=closed", "flagon-io"),
88+ "/flagon-io/g1t/pulls?state=closed",
89+ );
90+});
+16−0
121121 }
122122
123123 /**
124+ * The page at `pathname` under the workspace `slug` instead of its first
125+ * segment, keeping its query: where an old name or an alias leads. A click
126+ * asks for the page's data at `<path>.data?_routes=…`; the address given is
127+ * the page's own, which the browser then asks for as usual.
128+ */
129+export function underWorkspace(pathname: string, search: string, slug: string): string {
130+ const params = new URLSearchParams(search);
131+ params.delete("_routes");
132+ const query = params.toString();
133+ const segments = pagePath(pathname).split("/");
134+ // segments[0] is the empty string before the leading slash.
135+ segments[1] = slug;
136+ return `${segments.join("/")}${query ? `?${query}` : ""}`;
137+}
138+
139+/**
124140 * Where an old address of a workspace's pages is now, keeping its query;
125141 * null when it has not moved. `/<workspace>?tab=projects` and the like
126142 * open that page.
+5−3
22
33 import type { Route } from "./+types/not-found";
44 import { redirectIfRenamed } from "../lib/renamed.server";
5+import { pagePath } from "../lib/workspace-nav";
56
67 /**
78 * Any address no other route matches. Throwing the 404 from a route, rather
89 * than leaving the router to, means the root loader still runs, so someone
910 * signed in sees the page in their own sidebar and not the public frame.
1011 *
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.
1315 */
1416 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];
1618 if (first) await redirectIfRenamed(request, first);
1719 throw data(null, { status: 404 });
1820 }
+25−3
11 import { createRequestHandler } from "react-router";
22
3+import { identityClient, isNamespaceShaped } from "@g1t/contracts";
4+
35 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
46 import { goImport } from "../app/lib/go-get";
57 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";
79
810 const requestHandler = createRequestHandler(
911 () => import("virtual:react-router/server-build"),
162164 /**
163165 * A registry request, answered by the packages service as it is: its
164166 * 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.
166170 */
167171 async function proxyPackages(env: Env, request: Request): Promise<Response> {
168172 const started = Date.now();
169173 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);
171176 response.headers.append("server-timing", `packages;dur=${Date.now() - started}`);
172177 return response;
173178 }
174179
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+
175197 /**
176198 * An uploaded avatar. Its address is its hash, so it never changes and is
177199 * kept for good. It is served as nothing but an image: the stored type,
+53−1
560560 // `resolve_slug` takes `SlugArgs` and returns `Option<String>`: the
561561 // workspace's current slug when `slug` is one it was renamed from within
562562 // 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+}
564616
565617 /// `set_workspace_avatar`: owners only. `image` is the file's bytes in
566618 /// base64: PNG, JPEG, WebP or GIF, at most `MAX_AVATAR_BYTES`. Null removes
+4−1
3838 pub mod work;
3939
4040 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+};
4245 pub use outcome::{Failure, FailureCode, Outcome};
4346
4447 use serde::{Deserialize, Serialize};
+51−17
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] = &[
34 "api",
45 "mcp",
56 "login",
1920 "avatars",
2021 "docs",
2122 "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",
2623 "about",
2724 "pricing",
2825 "terms",
5047 "notifications",
5148 ];
5249
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+
5355 /// Whether `value`, whatever its case, is a name nobody can register or
5456 /// rename a workspace to: a route, or g1t's own.
5557 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 {
5663 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("--")
5880 }
5981
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+
6091 /// A username or workspace slug as someone typed it, trimmed and
6192 /// lowercased, if it can be registered: what signing up, signing up with
6293 /// GitHub and creating a workspace each take. None when it is malformed or
69100 /// Namespaces follow GitHub's rules: letters, digits and single hyphens,
70101 /// not starting or ending with a hyphen, at most 39 characters.
71102 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)
82104 }
83105
84106 pub fn is_valid_repo_name(value: &str) -> bool {
114136 assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana"));
115137 assert_eq!(claimable_namespace("an--a"), None);
116138 }
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+ }
117151 }
+20−0
13121312 MCP clients, the CLI (device flow) and third-party apps all use it. Access
13131313 tokens and SSH keys remain for git itself.
13141314
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+
13151335 ## Architecture
13161336
13171337 | Component | Language | Runs on | Responsibility |
+3−0
272272 deletedWorkspaces: () => call("admin_deleted_workspaces", {}),
273273 restoreWorkspace: (workspaceId, staff) => call("admin_restore_workspace", { workspaceId, staff }),
274274 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 }),
275278 };
276279 }
277280
+32−1
316316 * `workspace.deleted`.
317317 */
318318 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>>;
319330 }
320331
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+
321351 /** Who is asking. Every read and write in every service takes one. */
322352 export type Viewer = User | null;
323353
549579 checkWorkspaceRename(actor: User, slug: string, newSlug: string): Promise<Result<boolean>>;
550580 /**
551581 * 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.
553584 */
554585 resolveSlug(slug: string): Promise<string | null>;
555586 /**
+9−1
2626 }
2727
2828 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);
3038 }
3139
3240 export function isValidRepoName(value: string): boolean {
+21−0
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;
+383−0
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+}
+6−2
608608 self.db
609609 .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?")
610610 .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()])?,
611615 // Only while it is still deleted: a restore a moment ago wins.
612616 self.db
613617 .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL")
627631 }
628632
629633 /// 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(
631635 &self,
632636 slug: &str,
633637 actor: AuditActor,
668672
669673 /// A line in sudo's audit log (billing keeps it), naming the staff
670674 /// 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) {
672676 let Ok(billing) = self.env.service("BILLING") else {
673677 return;
674678 };
+6−0
55
66 mod access;
77 mod admin;
8+mod aliases;
89 mod avatars;
910 mod crypto;
1011 mod deletion;
736737 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
737738 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
738739 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
740+ "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?),
739741 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
740742 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
741743 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
891893 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
892894 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
893895 "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?),
894900 _ => Response::error("Unknown method", 404),
895901 };
896902 served.finish(answered)
+22−6
5656 pub last_renamed_at: Option<&'a str>,
5757 /// A deleted workspace had `wanted`; it is never given to another.
5858 pub deleted: bool,
59+ /// Staff made `wanted` an alias (aliases.rs); it stays theirs.
60+ pub aliased: bool,
5961 pub now_ms: u64,
6062 }
6163
8183 );
8284 }
8385 }
84− if facts.someone_elses_username || facts.another_workspace || facts.deleted {
86+ if facts.someone_elses_username || facts.another_workspace || facts.deleted || facts.aliased {
8587 return refuse(FailureCode::Conflict, TAKEN);
8688 }
8789 if let Some((holder, created_at)) = facts.redirect
131133 }
132134
133135 /// 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.
135138 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?)
137140 }
138141
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).
140144 pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> {
141145 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 {
143148 return Ok(None);
144149 }
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())))
146156 }
147157
148158 /// Checks a rename, returning the workspace's id when it is allowed.
203213 .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())),
204214 last_renamed_at: last_renamed_at.as_deref(),
205215 deleted: self.slug_deleted(&wanted).await?,
216+ aliased: self.is_alias(&wanted).await?,
206217 now_ms: now_ms(),
207218 };
208219 Ok(match check(&facts) {
364375 ..facts("acme", "initech")
365376 };
366377 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);
367383 }
368384
369385 #[test]
+2−1
22 // service answers anonymous git requests with `wrangler dev` (repos.jsonc).
33 //
44 // - 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.
66 // - Events takes every event and audit entry and logs them.
77 // - Security has allowed no secrets; billing says every workspace is free.
88
1414 switch (method) {
1515 case "user_for_git_credentials":
1616 case "resolve_slug":
17+ case "resolve_alias":
1718 return json(null);
1819 case "is_free":
1920 case "plan":
+54−1
3232 pub service: GitService,
3333 }
3434
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+
3550 /// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
3651 /// request is not git's.
3752 pub fn parse(url: &Url) -> Option<GitRequest> {
216231 Ok(current.and_then(|slug| with_namespace(url, &slug)))
217232 }
218233
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+
219249 /// `url` with its repository, the first two path segments, replaced by
220250 /// `to`: where a request for a transferred repository's old path goes.
221251 /// Keeps whether the old address ended in `.git`.
10501080
10511081 #[cfg(test)]
10521082 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};
10541084
10551085 #[test]
10561086 fn server_timing_names_each_step_and_the_total() {
11591189 }
11601190
11611191 #[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]
11621215 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
11631216 let url = worker::Url::parse(
11641217 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
+18−1
15681568 };
15691569 let (found, viewer) =
15701570 futures_util::future::join(lookup, git_http::viewer(&request, &identity)).await;
1571− let found = found?;
1571+ let mut found = found?;
15721572 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);
15731590 if found.is_none() {
15741591 // A workspace that was renamed: git follows a redirect when it
15751592 // first asks for refs, and uses the new address from then on.