Site: /g1t leads to /flagon-io, registries follow, and @g1t stays the agent
A page under a workspace alias or old name 301s to the same page under the workspace, keeping the query; a click's .data request is sent to the page, without _routes. Reserved names are looked up, as aliases can be reserved. A package registry request that finds nothing under an alias or old name 301s (308 for a publish) to the same path under the workspace. @g1t in markdown links to how g1t's agent works, never to /g1t.
10 files+154−240/10 viewed
| 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 | /** | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 5 | ||
| 6 | 6 | test("a path is one of the workspace's pages, or none", () => { | |
| 7 | 7 | assert.equal(workspacePage("/acme", "acme"), "overview"); | |
| ⋯ | |||
| 73 | 73 | assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=routes%2Fworkspace%2Fmoved-members"), "/acme/-/people"); | |
| 74 | 74 | assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=x&q=ada"), "/acme/-/people?q=ada"); | |
| 75 | 75 | }); | |
| 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 | + | }); | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | 123 | /** | |
| 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 | + | /** | |
| 124 | 140 | * Where an old address of a workspace's pages is now, keeping its query; | |
| 125 | 141 | * null when it has not moved. `/<workspace>?tab=projects` and the like | |
| 126 | 142 | * open that page. |
| 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, | |
| 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 { |