Skip to content

Commit

Sudo: Aliases, to add and remove workspace aliases with a reason

Lists each alias with its workspace, note, and who added it when; adds one (alias, workspace, why) and removes one (why), in plain forms. Both are in the audit log as alias_added and alias_removed.

syntaqxcommitted Parentc14fd2eBrowse files
10 files+282−20/10 viewed
+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+}
+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 /**