Workspaces own repositories
There is no longer a namespace per user. A person creates a workspace, which may share their username, and repositories belong to it; a team is a workspace with more members. Authorization is by membership. - identity: workspaces, members and roles; every user resolved from credentials carries their memberships - repos: read, write, create and push-to-create check membership - site: create-workspace flow for new accounts, workspace pages with members, workspace choice when creating a repository - api: create_workspace; create_repo takes a workspace - docs and llms.txt updated
30 files+879−1110/30 viewed
| 34 | 34 | input?: (params: Record<string, string>, query: Input, body: Input) => Input; | |
| 35 | 35 | }[] = [ | |
| 36 | 36 | { method: "GET", path: "/v1/user", operation: "whoami" }, | |
| 37 | + | { method: "POST", path: "/v1/workspaces", operation: "create_workspace", input: (_p, _q, b) => b }, | |
| 37 | 38 | { method: "GET", path: "/v1/repos", operation: "list_repos", input: (_p, q) => ({ query: q.q }) }, | |
| 38 | 39 | { method: "POST", path: "/v1/repos", operation: "create_repo", input: (_p, _q, b) => b }, | |
| 39 | 40 | { method: "GET", path: "/v1/repos/:owner/:name", operation: "get_repo", input: repo }, | |
| 57 | 58 | ||
| 58 | 59 | /** The section of the API reference an operation is listed under. */ | |
| 59 | 60 | function tagFor(operation: string): string { | |
| 60 | − | if (operation === "whoami") return "Accounts"; | |
| 61 | + | if (operation === "whoami" || operation.includes("workspace")) return "Accounts"; | |
| 61 | 62 | if (operation.includes("session")) return "Sessions"; | |
| 62 | 63 | if (operation.includes("attempt")) return "Attempts"; | |
| 63 | 64 | if (operation.includes("intent")) return "Intents"; |
| 68 | 68 | export const operations: Operation[] = [ | |
| 69 | 69 | { | |
| 70 | 70 | name: "whoami", | |
| 71 | − | description: "The account the access token belongs to.", | |
| 71 | + | description: "The account the access token belongs to, and its workspaces.", | |
| 72 | 72 | input: { type: "object", properties: {} }, | |
| 73 | 73 | run: authed(async (_env, user) => ok(user)), | |
| 74 | 74 | }, | |
| 75 | 75 | { | |
| 76 | + | name: "create_workspace", | |
| 77 | + | description: | |
| 78 | + | "Create a workspace. A workspace owns repositories and is the first part of their address, g1t.sh/<workspace>/<repo>. The whoami tool lists the ones you already belong to.", | |
| 79 | + | input: { | |
| 80 | + | type: "object", | |
| 81 | + | properties: { | |
| 82 | + | slug: { | |
| 83 | + | type: "string", | |
| 84 | + | description: "Its name in URLs: lowercase letters, digits and single hyphens.", | |
| 85 | + | }, | |
| 86 | + | name: { type: "string", description: "A display name." }, | |
| 87 | + | }, | |
| 88 | + | required: ["slug"], | |
| 89 | + | }, | |
| 90 | + | run: authed((env, user, input) => | |
| 91 | + | env.IDENTITY.createWorkspace(user, text(input, "slug"), text(input, "name")), | |
| 92 | + | ), | |
| 93 | + | }, | |
| 94 | + | { | |
| 76 | 95 | name: "list_repos", | |
| 77 | 96 | description: "Repositories you can see, optionally filtered by a search query.", | |
| 78 | 97 | input: { | |
| 93 | 112 | }, | |
| 94 | 113 | { | |
| 95 | 114 | name: "create_repo", | |
| 96 | − | description: "Create a repository under your account.", | |
| 115 | + | description: "Create a repository in one of your workspaces.", | |
| 97 | 116 | input: { | |
| 98 | 117 | type: "object", | |
| 99 | 118 | properties: { | |
| 119 | + | workspace: { | |
| 120 | + | type: "string", | |
| 121 | + | description: | |
| 122 | + | "The workspace to create it in. May be left out if you belong to exactly one.", | |
| 123 | + | }, | |
| 100 | 124 | name: { type: "string" }, | |
| 101 | 125 | description: { type: "string" }, | |
| 102 | 126 | private: { type: "boolean" }, | |
| 105 | 129 | }, | |
| 106 | 130 | run: authed((env, user, input) => | |
| 107 | 131 | env.REPOS.create(user, { | |
| 132 | + | namespace: | |
| 133 | + | text(input, "workspace") || | |
| 134 | + | (user.workspaces?.length === 1 ? user.workspaces[0].slug : ""), | |
| 108 | 135 | name: text(input, "name"), | |
| 109 | 136 | description: text(input, "description"), | |
| 110 | 137 | isPrivate: input.private === true, |
| 52 | 52 | ||
| 53 | 53 | ## Shipping | |
| 54 | 54 | ||
| 55 | − | The owner of a repository ships an attempt to land it. Shipping moves `main` | |
| 55 | + | A member of the repository's workspace ships an attempt to land it. Shipping moves `main` | |
| 56 | 56 | to the attempt's head commit, marks the attempt `shipped` and closes the | |
| 57 | 57 | intent. | |
| 58 | 58 |
| 6 | 6 | ## Creating an account | |
| 7 | 7 | ||
| 8 | 8 | Register at [g1t.sh/register](https://g1t.sh/register). Usernames are | |
| 9 | − | lowercase letters, digits and single hyphens, up to 39 characters. Your | |
| 10 | − | username is your namespace: `g1t.sh/<username>`. | |
| 9 | + | lowercase letters, digits and single hyphens, up to 39 characters. | |
| 11 | 10 | ||
| 12 | 11 | Accounts can only be created in a browser. There is no API for it, by | |
| 13 | 12 | design: it keeps passwords out of scripts and agents, and lets g1t protect | |
| 22 | 21 | message telling you to confirm your address. To get a new link, sign in and | |
| 23 | 22 | use the banner at the top of the site. | |
| 24 | 23 | ||
| 24 | + | ## Workspaces | |
| 25 | + | ||
| 26 | + | A workspace owns repositories and is the first part of their address: | |
| 27 | + | `g1t.sh/<workspace>/<repo>`. There is one kind. A workspace for just you and | |
| 28 | + | one for a company are the same thing with a different number of members, so | |
| 29 | + | there is no separate notion of an organization. | |
| 30 | + | ||
| 31 | + | Your account does not own repositories itself. After confirming your email | |
| 32 | + | you create a workspace, which can have the same name as your username, and | |
| 33 | + | repositories go in it. You can belong to up to ten. | |
| 34 | + | ||
| 35 | + | | Role | Can | | |
| 36 | + | | --- | --- | | |
| 37 | + | | Member | Create repositories, push, open intents, ship attempts. | | |
| 38 | + | | Owner | Everything a member can, and add or remove members. | | |
| 39 | + | ||
| 40 | + | Manage members on the workspace's page, `g1t.sh/<workspace>`. | |
| 41 | + | ||
| 25 | 42 | ## Access tokens | |
| 26 | 43 | ||
| 27 | 44 | A token stands in for your password everywhere outside the website: |
| 8 | 8 | ## Remotes | |
| 9 | 9 | ||
| 10 | 10 | ```text | |
| 11 | − | https://g1t.sh/<owner>/<repo>.git | |
| 11 | + | https://g1t.sh/<workspace>/<repo>.git | |
| 12 | 12 | ``` | |
| 13 | 13 | ||
| 14 | 14 | Public repositories can be cloned without signing in: | |
| 32 | 32 | ||
| 33 | 33 | ## Creating a repository by pushing | |
| 34 | 34 | ||
| 35 | − | Pushing to a repository that does not exist under your own username creates | |
| 36 | − | it as a public repository. | |
| 35 | + | Pushing to a repository that does not exist, in a workspace you belong to, | |
| 36 | + | creates it as a public repository. | |
| 37 | 37 | ||
| 38 | 38 | ```sh | |
| 39 | − | git push https://g1t.sh/<username>/new-repo.git main | |
| 39 | + | git push https://g1t.sh/<workspace>/new-repo.git main | |
| 40 | 40 | ``` | |
| 41 | 41 | ||
| 42 | 42 | ## Private repositories | |
| 43 | 43 | ||
| 44 | − | A private repository is visible only to its owner. To everyone else it looks | |
| 44 | + | A private repository is visible only to members of its workspace. To everyone | |
| 45 | + | else it looks | |
| 45 | 46 | exactly like a repository that does not exist, both on the site and to git. | |
| 46 | 47 | ||
| 47 | 48 | ## Attempt forks |
| 15 | 15 | ||
| 16 | 16 | ## 1. Create an account | |
| 17 | 17 | ||
| 18 | − | [Sign up](https://g1t.sh/register) with a username, email and password. Your username is | |
| 19 | − | your namespace: your repositories live at `g1t.sh/<username>/<repo>`. | |
| 18 | + | [Sign up](https://g1t.sh/register) with a username, email and password, and | |
| 19 | + | confirm your email from the message g1t sends. | |
| 20 | + | ||
| 21 | + | Then create a **workspace**. A workspace owns repositories and is the first | |
| 22 | + | part of their address: `g1t.sh/<workspace>/<repo>`. Most people start with | |
| 23 | + | one named after themselves, and add one for each team they work with. | |
| 20 | 24 | ||
| 21 | 25 | ## 2. Create an access token | |
| 22 | 26 | ||
| 35 | 39 | ||
| 36 | 40 | ```sh | |
| 37 | 41 | cd my-project | |
| 38 | − | git remote add g1t https://g1t.sh/<username>/my-project.git | |
| 42 | + | git remote add g1t https://g1t.sh/<workspace>/my-project.git | |
| 39 | 43 | git push -u g1t main | |
| 40 | 44 | ``` | |
| 41 | 45 | ||
| 42 | 46 | You can also create an empty repository from the **+** button in the header, | |
| 43 | − | and choose whether it is public or private. | |
| 47 | + | and choose its workspace and whether it is public or private. | |
| 44 | 48 | ||
| 45 | 49 | ## 4. Open an intent | |
| 46 | 50 | ||
| 61 | 65 | ||
| 62 | 66 | Then ask it to work on the intent: | |
| 63 | 67 | ||
| 64 | − | > Look at the open intents on `<username>/my-project` on g1t, start an attempt | |
| 68 | + | > Look at the open intents on `<workspace>/my-project` on g1t, start an attempt | |
| 65 | 69 | > on the first one, and record your session as you go. | |
| 66 | 70 | ||
| 67 | 71 | The agent gets its own fork of the repository, pushes its commits there, and |
| 1 | 1 | import { | |
| 2 | 2 | BookOpen, | |
| 3 | + | Building2, | |
| 3 | 4 | ChevronDown, | |
| 4 | 5 | LayoutDashboard, | |
| 5 | 6 | LogOut, | |
| 6 | 7 | Plus, | |
| 7 | 8 | Search, | |
| 8 | 9 | Settings, | |
| 9 | − | UserRound, | |
| 10 | 10 | } from "lucide-react"; | |
| 11 | 11 | import { | |
| 12 | 12 | Form, | |
| 121 | 121 | </span> | |
| 122 | 122 | </DropdownMenuLabel> | |
| 123 | 123 | <DropdownMenuSeparator /> | |
| 124 | + | {(user.workspaces ?? []).map((membership) => ( | |
| 125 | + | <DropdownMenuItem asChild key={membership.slug}> | |
| 126 | + | <Link to={`/${membership.slug}`}> | |
| 127 | + | <Building2 /> | |
| 128 | + | {membership.slug} | |
| 129 | + | </Link> | |
| 130 | + | </DropdownMenuItem> | |
| 131 | + | ))} | |
| 124 | 132 | <DropdownMenuItem asChild> | |
| 125 | − | <Link to={`/${user.username}`}> | |
| 126 | − | <UserRound /> | |
| 127 | − | Your profile | |
| 133 | + | <Link to="/workspaces/new"> | |
| 134 | + | <Plus /> | |
| 135 | + | New workspace | |
| 128 | 136 | </Link> | |
| 129 | 137 | </DropdownMenuItem> | |
| 138 | + | <DropdownMenuSeparator /> | |
| 130 | 139 | <DropdownMenuItem asChild> | |
| 131 | 140 | <Link to="/"> | |
| 132 | 141 | <LayoutDashboard /> |
| 13 | 13 | route("settings", "routes/settings.tsx"), | |
| 14 | 14 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 15 | 15 | route("search", "routes/explore.tsx", { id: "search" }), | |
| 16 | − | route(":owner", "routes/profile.tsx"), | |
| 16 | + | route("workspaces/new", "routes/workspace-new.tsx"), | |
| 17 | + | route(":owner", "routes/workspace.tsx"), | |
| 17 | 18 | route(":owner/:repo", "routes/repo/layout.tsx", [ | |
| 18 | 19 | index("routes/repo/code.tsx"), | |
| 19 | 20 | route("tree/:ref/*", "routes/repo/tree.tsx"), |
| 1 | 1 | import { ArrowRight, Plus } from "lucide-react"; | |
| 2 | − | import { Link } from "react-router"; | |
| 2 | + | import { Link, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/home"; | |
| 5 | 5 | import { Landing } from "../components/landing"; | |
| 28 | 28 | ||
| 29 | 29 | export async function loader({ context }: Route.LoaderArgs) { | |
| 30 | 30 | const viewer = getViewer(context); | |
| 31 | + | // Nothing can be created outside a workspace, so a new account starts there. | |
| 32 | + | if (viewer?.verified && (viewer.workspaces ?? []).length === 0) { | |
| 33 | + | throw redirect("/workspaces/new"); | |
| 34 | + | } | |
| 31 | 35 | const [repos, attempts] = await Promise.all([ | |
| 32 | − | reposApi.list(viewer, viewer ? { namespace: viewer.username } : {}), | |
| 36 | + | reposApi.list(viewer, { memberOnly: Boolean(viewer) }), | |
| 33 | 37 | work.listActiveAttempts(viewer), | |
| 34 | 38 | ]); | |
| 35 | 39 | // Mission control links to each attempt under its repo. |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | export function loader({ request, context }: Route.LoaderArgs) { | |
| 13 | − | return { user: requireUser(context, request) }; | |
| 13 | + | const user = requireUser(context, request); | |
| 14 | + | const workspaces = (user.workspaces ?? []).map((membership) => membership.slug); | |
| 15 | + | // Repositories live in a workspace, so there has to be one first. | |
| 16 | + | if (workspaces.length === 0) throw redirect("/workspaces/new"); | |
| 17 | + | const asked = new URL(request.url).searchParams.get("workspace"); | |
| 18 | + | return { | |
| 19 | + | workspaces, | |
| 20 | + | selected: asked && workspaces.includes(asked) ? asked : workspaces[0], | |
| 21 | + | }; | |
| 14 | 22 | } | |
| 15 | 23 | ||
| 16 | 24 | export async function action({ request, context }: Route.ActionArgs) { | |
| 18 | 26 | const user = requireUser(context, request); | |
| 19 | 27 | const form = await request.formData(); | |
| 20 | 28 | const result = await repos.create(user, { | |
| 29 | + | namespace: String(form.get("workspace") ?? ""), | |
| 21 | 30 | name: String(form.get("name") ?? ""), | |
| 22 | 31 | description: String(form.get("description") ?? ""), | |
| 23 | 32 | isPrivate: form.get("visibility") === "private", | |
| 36 | 45 | <Form method="post" className="mt-8 space-y-4"> | |
| 37 | 46 | <Field label="Name"> | |
| 38 | 47 | <div className="flex items-center gap-2 font-mono text-sm"> | |
| 39 | − | <span className="text-muted">{loaderData.user.username}/</span> | |
| 48 | + | <select | |
| 49 | + | name="workspace" | |
| 50 | + | defaultValue={loaderData.selected} | |
| 51 | + | aria-label="Workspace" | |
| 52 | + | className="rounded-md border border-line bg-bg px-2 py-2 text-sm" | |
| 53 | + | > | |
| 54 | + | {loaderData.workspaces.map((slug) => ( | |
| 55 | + | <option key={slug} value={slug}> | |
| 56 | + | {slug} | |
| 57 | + | </option> | |
| 58 | + | ))} | |
| 59 | + | </select> | |
| 60 | + | <span className="text-muted">/</span> | |
| 40 | 61 | <Input name="name" required autoFocus maxLength={100} /> | |
| 41 | 62 | </div> | |
| 42 | 63 | </Field> |
| 1 | − | import { data } from "react-router"; | |
| 2 | − | ||
| 3 | − | import type { Route } from "./+types/profile"; | |
| 4 | − | import { RepoList } from "../components/repo-list"; | |
| 5 | − | import { Avatar } from "../components/ui"; | |
| 6 | − | import { identity, repos as reposApi } from "../lib/services.server"; | |
| 7 | − | import { getViewer } from "../lib/session.server"; | |
| 8 | − | ||
| 9 | − | export function meta({ params }: Route.MetaArgs) { | |
| 10 | − | return [{ title: `${params.owner} · g1t` }]; | |
| 11 | − | } | |
| 12 | − | ||
| 13 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 14 | − | const [user, repos] = await Promise.all([ | |
| 15 | − | identity.userByUsername(params.owner), | |
| 16 | − | reposApi.list(getViewer(context), { namespace: params.owner }), | |
| 17 | − | ]); | |
| 18 | − | if (!user) throw data(null, { status: 404 }); | |
| 19 | − | return { user, repos }; | |
| 20 | − | } | |
| 21 | − | ||
| 22 | − | export default function Profile({ loaderData }: Route.ComponentProps) { | |
| 23 | − | const { user, repos } = loaderData; | |
| 24 | − | return ( | |
| 25 | − | <main className="mx-auto max-w-4xl px-4 py-10"> | |
| 26 | − | <div className="flex items-center gap-4"> | |
| 27 | − | <Avatar name={user.username} size={56} /> | |
| 28 | − | <div> | |
| 29 | − | <h1 className="font-mono text-2xl font-semibold tracking-tight"> | |
| 30 | − | {user.username} | |
| 31 | − | </h1> | |
| 32 | − | <p className="text-sm text-muted"> | |
| 33 | − | {repos.length} {repos.length === 1 ? "repository" : "repositories"} | |
| 34 | − | </p> | |
| 35 | − | </div> | |
| 36 | − | </div> | |
| 37 | − | <h2 className="mt-10 text-sm font-medium text-muted">Repositories</h2> | |
| 38 | − | <RepoList repos={repos} /> | |
| 39 | − | </main> | |
| 40 | − | ); | |
| 41 | − | } |
| 69 | 69 | comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON), | |
| 70 | 70 | session: unwrap(session), | |
| 71 | 71 | viewer, | |
| 72 | − | // Only the repository's owner can land an attempt. | |
| 73 | − | canShip: repo.ok && repo.value.ownerId === viewer?.id, | |
| 72 | + | // Members of the repository's workspace can land an attempt. | |
| 73 | + | canShip: | |
| 74 | + | repo.ok && | |
| 75 | + | (viewer?.workspaces ?? []).some( | |
| 76 | + | (membership) => membership.slug === repo.value.namespace, | |
| 77 | + | ), | |
| 74 | 78 | defaultBranch: repo.ok ? repo.value.defaultBranch : "main", | |
| 75 | 79 | }; | |
| 76 | 80 | } |
| 1 | + | import { Form, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/workspace-new"; | |
| 4 | + | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 5 | + | import { identity } from "../lib/services.server"; | |
| 6 | + | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 7 | + | ||
| 8 | + | export function meta({}: Route.MetaArgs) { | |
| 9 | + | return [{ title: "New workspace · g1t" }]; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export function loader({ request, context }: Route.LoaderArgs) { | |
| 13 | + | const user = requireUser(context, request); | |
| 14 | + | return { user, first: (user.workspaces ?? []).length === 0 }; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 18 | + | assertSameOrigin(request); | |
| 19 | + | const user = requireUser(context, request); | |
| 20 | + | const form = await request.formData(); | |
| 21 | + | const result = await identity.createWorkspace( | |
| 22 | + | user, | |
| 23 | + | String(form.get("slug") ?? ""), | |
| 24 | + | String(form.get("name") ?? ""), | |
| 25 | + | ); | |
| 26 | + | if (!result.ok) return { error: result.error.message }; | |
| 27 | + | throw redirect(`/${result.value.slug}`); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | export default function NewWorkspace({ | |
| 31 | + | loaderData, | |
| 32 | + | actionData, | |
| 33 | + | }: Route.ComponentProps) { | |
| 34 | + | const { user, first } = loaderData; | |
| 35 | + | return ( | |
| 36 | + | <main className="mx-auto max-w-lg px-4 py-12"> | |
| 37 | + | <h1 className="text-xl font-semibold"> | |
| 38 | + | {first ? "Create your first workspace" : "New workspace"} | |
| 39 | + | </h1> | |
| 40 | + | <p className="mt-2 text-sm text-muted"> | |
| 41 | + | A workspace owns repositories and is the first part of their address:{" "} | |
| 42 | + | <span className="font-mono text-fg">g1t.sh/workspace/repo</span>. Use | |
| 43 | + | one for yourself, and one for each team or company you work with. | |
| 44 | + | </p> | |
| 45 | + | <Form method="post" className="mt-8 space-y-4"> | |
| 46 | + | <Field | |
| 47 | + | label="Name in URLs" | |
| 48 | + | hint="Lowercase letters, digits and single hyphens. It cannot be changed later." | |
| 49 | + | > | |
| 50 | + | <div className="flex items-center gap-2 font-mono text-sm"> | |
| 51 | + | <span className="text-muted">g1t.sh/</span> | |
| 52 | + | <Input | |
| 53 | + | name="slug" | |
| 54 | + | required | |
| 55 | + | autoFocus | |
| 56 | + | maxLength={39} | |
| 57 | + | defaultValue={first ? user.username : ""} | |
| 58 | + | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" | |
| 59 | + | /> | |
| 60 | + | </div> | |
| 61 | + | </Field> | |
| 62 | + | <Field label="Display name (optional)"> | |
| 63 | + | <Input name="name" maxLength={80} /> | |
| 64 | + | </Field> | |
| 65 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 66 | + | <Button type="submit">Create workspace</Button> | |
| 67 | + | </Form> | |
| 68 | + | </main> | |
| 69 | + | ); | |
| 70 | + | } |
| 1 | + | import { Plus, Users } from "lucide-react"; | |
| 2 | + | import { Form, data } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/workspace"; | |
| 5 | + | import { RepoList } from "../components/repo-list"; | |
| 6 | + | import { Avatar, Button, ButtonLink, ErrorText, Input, Pill } from "../components/ui"; | |
| 7 | + | import { identity, repos as reposApi } from "../lib/services.server"; | |
| 8 | + | import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server"; | |
| 9 | + | ||
| 10 | + | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 11 | + | return [{ title: `${loaderData?.workspace.name ?? params.owner} · g1t` }]; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 15 | + | const viewer = getViewer(context); | |
| 16 | + | const workspace = await identity.getWorkspace(params.owner); | |
| 17 | + | if (!workspace) throw data(null, { status: 404 }); | |
| 18 | + | const role = | |
| 19 | + | viewer?.workspaces?.find((membership) => membership.slug === workspace.slug) | |
| 20 | + | ?.role ?? null; | |
| 21 | + | const [repos, members] = await Promise.all([ | |
| 22 | + | reposApi.list(viewer, { namespace: workspace.slug }), | |
| 23 | + | role ? identity.listMembers(workspace.slug, viewer) : null, | |
| 24 | + | ]); | |
| 25 | + | return { | |
| 26 | + | workspace, | |
| 27 | + | repos, | |
| 28 | + | role, | |
| 29 | + | members: members?.ok ? members.value : null, | |
| 30 | + | }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 34 | + | assertSameOrigin(request); | |
| 35 | + | const user = requireUser(context, request); | |
| 36 | + | const form = await request.formData(); | |
| 37 | + | const username = String(form.get("username") ?? ""); | |
| 38 | + | const result = | |
| 39 | + | form.get("action") === "remove" | |
| 40 | + | ? await identity.removeMember(user, params.owner, username) | |
| 41 | + | : await identity.addMember(user, params.owner, username); | |
| 42 | + | return result.ok ? null : { error: result.error.message }; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | export default function WorkspacePage({ | |
| 46 | + | loaderData, | |
| 47 | + | actionData, | |
| 48 | + | }: Route.ComponentProps) { | |
| 49 | + | const { workspace, repos, role, members } = loaderData; | |
| 50 | + | return ( | |
| 51 | + | <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_18rem]"> | |
| 52 | + | <div className="min-w-0"> | |
| 53 | + | <div className="flex items-center gap-4"> | |
| 54 | + | <Avatar name={workspace.slug} size={56} /> | |
| 55 | + | <div className="min-w-0 grow"> | |
| 56 | + | <h1 className="truncate text-2xl font-semibold tracking-tight"> | |
| 57 | + | {workspace.name} | |
| 58 | + | </h1> | |
| 59 | + | <p className="font-mono text-sm text-muted"> | |
| 60 | + | g1t.sh/{workspace.slug} | |
| 61 | + | </p> | |
| 62 | + | </div> | |
| 63 | + | {role && ( | |
| 64 | + | <ButtonLink to={`/new?workspace=${workspace.slug}`}> | |
| 65 | + | <Plus size={15} /> | |
| 66 | + | New repository | |
| 67 | + | </ButtonLink> | |
| 68 | + | )} | |
| 69 | + | </div> | |
| 70 | + | <h2 className="mt-10 text-sm font-medium text-muted">Repositories</h2> | |
| 71 | + | <RepoList repos={repos} /> | |
| 72 | + | </div> | |
| 73 | + | ||
| 74 | + | <aside> | |
| 75 | + | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 76 | + | <Users size={15} className="text-faint" /> | |
| 77 | + | {workspace.memberCount}{" "} | |
| 78 | + | {workspace.memberCount === 1 ? "member" : "members"} | |
| 79 | + | </h2> | |
| 80 | + | {members && ( | |
| 81 | + | <ul className="mt-3 space-y-1"> | |
| 82 | + | {members.map((member) => ( | |
| 83 | + | <li | |
| 84 | + | key={member.username} | |
| 85 | + | className="flex items-center gap-2 rounded-md px-1 py-1 text-sm" | |
| 86 | + | > | |
| 87 | + | <Avatar name={member.username} /> | |
| 88 | + | <span className="grow font-mono">{member.username}</span> | |
| 89 | + | {member.role === "owner" ? ( | |
| 90 | + | <Pill>owner</Pill> | |
| 91 | + | ) : ( | |
| 92 | + | role === "owner" && ( | |
| 93 | + | <Form method="post"> | |
| 94 | + | <input type="hidden" name="action" value="remove" /> | |
| 95 | + | <input type="hidden" name="username" value={member.username} /> | |
| 96 | + | <button | |
| 97 | + | type="submit" | |
| 98 | + | className="text-xs text-faint hover:text-danger" | |
| 99 | + | > | |
| 100 | + | Remove | |
| 101 | + | </button> | |
| 102 | + | </Form> | |
| 103 | + | ) | |
| 104 | + | )} | |
| 105 | + | </li> | |
| 106 | + | ))} | |
| 107 | + | </ul> | |
| 108 | + | )} | |
| 109 | + | {role === "owner" && ( | |
| 110 | + | <Form method="post" className="mt-4 flex gap-2"> | |
| 111 | + | <Input name="username" placeholder="Username to add" required /> | |
| 112 | + | <Button variant="quiet" type="submit"> | |
| 113 | + | Add | |
| 114 | + | </Button> | |
| 115 | + | </Form> | |
| 116 | + | )} | |
| 117 | + | <div className="mt-2"> | |
| 118 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 119 | + | </div> | |
| 120 | + | {!role && ( | |
| 121 | + | <p className="mt-2 text-sm text-muted"> | |
| 122 | + | Members can create repositories here and ship changes to them. | |
| 123 | + | </p> | |
| 124 | + | )} | |
| 125 | + | </aside> | |
| 126 | + | </main> | |
| 127 | + | ); | |
| 128 | + | } |
| 57 | 57 | --header "Authorization: Bearer $G1T_TOKEN" | |
| 58 | 58 | ``` | |
| 59 | 59 | ||
| 60 | − | 5. **Push a repository.** Pushing to a repository that does not exist under | |
| 61 | − | the person's own username creates it, public by default. | |
| 60 | + | 5. **Create a workspace** if `GET /v1/user` shows none. A workspace owns | |
| 61 | + | repositories and is the first part of their address. Ask the person what | |
| 62 | + | to call it; their username is a sensible default. | |
| 62 | 63 | ||
| 63 | 64 | ```sh | |
| 64 | − | git remote add g1t https://g1t.sh/USERNAME/REPO.git | |
| 65 | + | curl -X POST https://api.g1t.sh/v1/workspaces \ | |
| 66 | + | -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \ | |
| 67 | + | -d '{"slug": "WORKSPACE"}' | |
| 68 | + | ``` | |
| 69 | + | ||
| 70 | + | 6. **Push a repository.** Pushing to a repository that does not exist, in a | |
| 71 | + | workspace the person belongs to, creates it, public by default. | |
| 72 | + | ||
| 73 | + | ```sh | |
| 74 | + | git remote add g1t https://g1t.sh/WORKSPACE/REPO.git | |
| 65 | 75 | git -c credential.helper= \ | |
| 66 | 76 | -c "http.extraHeader=Authorization: Basic $(printf '%s' "USERNAME:$G1T_TOKEN" | base64)" \ | |
| 67 | 77 | push -u g1t main | |
| 84 | 94 | sessions are as visible as the repository. | |
| 85 | 95 | - **Submit:** `POST /v1/attempts/{attempt_id}/submit` with `summary`. | |
| 86 | 96 | - **See what an attempt changed:** `GET /v1/attempts/{attempt_id}/changes`. | |
| 87 | − | - **Ship** (repository owner only): | |
| 97 | + | - **Ship** (members of the repository's workspace): | |
| 88 | 98 | `POST /v1/attempts/{attempt_id}/ship`. A `409` saying main has moved means | |
| 89 | 99 | the fork is behind: pull main from `https://g1t.sh/{owner}/{name}.git` into | |
| 90 | 100 | the fork, push, and ship again. | |
| 93 | 103 | `open_intent`, `start_attempt`, `record_session`, `submit_attempt`, | |
| 94 | 104 | `get_attempt_changes`, `ship_attempt`, and `list_intents`, `get_intent`, | |
| 95 | 105 | `get_attempt`, `read_session`, `list_repos`, `get_repo`, `create_repo`, | |
| 96 | − | `list_events`, `whoami`. | |
| 106 | + | `list_events`, `create_workspace`, `whoami`. | |
| 97 | 107 | ||
| 98 | 108 | ## Facts | |
| 99 | 109 | ||
| 102 | 112 | `{"error": {"code": "…", "message": "…"}}` with codes `unauthenticated` | |
| 103 | 113 | (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid` | |
| 104 | 114 | (422). The full description is at https://api.g1t.sh/openapi.json. | |
| 105 | − | - Git remote: `https://g1t.sh/{owner}/{repo}.git`. Attempt forks: | |
| 115 | + | - Git remote: `https://g1t.sh/{workspace}/{repo}.git`. In API paths, | |
| 116 | + | `{owner}` is the workspace. Attempt forks: | |
| 106 | 117 | `https://g1t.sh/attempts/{attempt_id}.git`. SSH is not available. | |
| 107 | 118 | - Limits: 1 GB per repository, 32 MB per file, 100 MB per push. | |
| 108 | 119 | - Forgotten password: https://g1t.sh/forgot (the person does this, in a |
| 213 | 213 | user: User, | |
| 214 | 214 | }, | |
| 215 | 215 | } | |
| 216 | + | ||
| 217 | + | /// A workspace: the owner of repositories, and the first segment of their | |
| 218 | + | /// URLs. A person's own space and a team's are the same thing. | |
| 219 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 220 | + | #[serde(rename_all = "camelCase")] | |
| 221 | + | pub struct Workspace { | |
| 222 | + | pub id: String, | |
| 223 | + | pub slug: String, | |
| 224 | + | pub name: String, | |
| 225 | + | /// RFC 3339. | |
| 226 | + | pub created_at: String, | |
| 227 | + | pub member_count: u32, | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 231 | + | pub struct Member { | |
| 232 | + | pub username: String, | |
| 233 | + | pub role: crate::Role, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// `create_workspace`. Returns `Outcome<Workspace>`. | |
| 237 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 238 | + | pub struct CreateWorkspaceArgs { | |
| 239 | + | pub user: User, | |
| 240 | + | pub slug: String, | |
| 241 | + | #[serde(default)] | |
| 242 | + | pub name: String, | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// `get_workspace`: public details, or null. Returns `Option<Workspace>`. | |
| 246 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 247 | + | pub struct SlugArgs { | |
| 248 | + | pub slug: String, | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// `list_members`: members only. Returns `Outcome<Vec<Member>>`. | |
| 252 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 253 | + | pub struct ListMembersArgs { | |
| 254 | + | pub slug: String, | |
| 255 | + | pub viewer: crate::Viewer, | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// `add_member` and `remove_member`: owners only. | |
| 259 | + | /// Each returns `Outcome<bool>`. | |
| 260 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 261 | + | pub struct MemberArgs { | |
| 262 | + | pub actor: User, | |
| 263 | + | pub slug: String, | |
| 264 | + | pub username: String, | |
| 265 | + | } |
| 19 | 19 | ||
| 20 | 20 | use serde::{Deserialize, Serialize}; | |
| 21 | 21 | ||
| 22 | + | /// What a member may do in a workspace. | |
| 23 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 24 | + | #[serde(rename_all = "lowercase")] | |
| 25 | + | pub enum Role { | |
| 26 | + | /// Everything a member can, plus managing members. | |
| 27 | + | Owner, | |
| 28 | + | /// Create repositories, push, open intents and ship. | |
| 29 | + | Member, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// One workspace a user belongs to. | |
| 22 | 33 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 34 | + | pub struct Membership { | |
| 35 | + | /// The workspace's name in URLs: `g1t.sh/<slug>`. | |
| 36 | + | pub slug: String, | |
| 37 | + | pub role: Role, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 23 | 41 | pub struct User { | |
| 24 | 42 | pub id: String, | |
| 25 | 43 | pub username: String, | |
| 27 | 45 | /// accounts can sign in but cannot create or change anything. | |
| 28 | 46 | #[serde(default)] | |
| 29 | 47 | pub verified: bool, | |
| 48 | + | /// The workspaces this user belongs to. Filled in when a user is | |
| 49 | + | /// resolved from credentials, so any service can authorize from it. | |
| 50 | + | #[serde(default)] | |
| 51 | + | pub workspaces: Vec<Membership>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | impl User { | |
| 55 | + | pub fn role_in(&self, slug: &str) -> Option<Role> { | |
| 56 | + | self.workspaces | |
| 57 | + | .iter() | |
| 58 | + | .find(|membership| membership.slug == slug) | |
| 59 | + | .map(|membership| membership.role) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | pub fn is_member(&self, slug: &str) -> bool { | |
| 63 | + | self.role_in(slug).is_some() | |
| 64 | + | } | |
| 30 | 65 | } | |
| 31 | 66 | ||
| 32 | 67 | /// Who is asking. Every read and write in every service takes one. |
| 1 | 1 | /// Routes and reserved words that may not be registered as usernames. | |
| 2 | 2 | const RESERVED: &[&str] = &[ | |
| 3 | − | "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth", | |
| 4 | − | "attempts", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing", "terms", | |
| 5 | − | "privacy", "help", "support", "status", "blog", "verify", "forgot", "reset", "device", | |
| 3 | + | "api", | |
| 4 | + | "mcp", | |
| 5 | + | "login", | |
| 6 | + | "logout", | |
| 7 | + | "register", | |
| 8 | + | "new", | |
| 9 | + | "settings", | |
| 10 | + | "search", | |
| 11 | + | "admin", | |
| 12 | + | "auth", | |
| 13 | + | "attempts", | |
| 14 | + | "oauth", | |
| 15 | + | "assets", | |
| 16 | + | "docs", | |
| 17 | + | "explore", | |
| 18 | + | "g1t", | |
| 19 | + | "about", | |
| 20 | + | "pricing", | |
| 21 | + | "terms", | |
| 22 | + | "privacy", | |
| 23 | + | "help", | |
| 24 | + | "support", | |
| 25 | + | "status", | |
| 26 | + | "blog", | |
| 27 | + | "verify", | |
| 28 | + | "forgot", | |
| 29 | + | "reset", | |
| 30 | + | "device", | |
| 31 | + | "workspaces", | |
| 32 | + | "u", | |
| 6 | 33 | ]; | |
| 7 | 34 | ||
| 8 | 35 | /// Namespaces follow GitHub's rules: letters, digits and single hyphens, |
| 11 | 11 | #[serde(rename_all = "camelCase")] | |
| 12 | 12 | pub struct Repo { | |
| 13 | 13 | pub id: String, | |
| 14 | − | /// The owning user's (later, workspace's) name: the first URL segment. | |
| 14 | + | /// The slug of the workspace that owns it: the first URL segment. | |
| 15 | 15 | pub namespace: String, | |
| 16 | 16 | pub name: String, | |
| 17 | 17 | pub description: Option<String>, | |
| 136 | 136 | ||
| 137 | 137 | /// `list`: repos the viewer may see, newest first. Returns `Vec<Repo>`. | |
| 138 | 138 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 139 | + | #[serde(rename_all = "camelCase")] | |
| 139 | 140 | pub struct ListArgs { | |
| 140 | 141 | pub viewer: Viewer, | |
| 141 | 142 | #[serde(default)] | |
| 142 | 143 | pub query: Option<String>, | |
| 144 | + | /// Only repos in this workspace. | |
| 143 | 145 | #[serde(default)] | |
| 144 | 146 | pub namespace: Option<String>, | |
| 147 | + | /// Only repos in workspaces the viewer belongs to. | |
| 148 | + | #[serde(default)] | |
| 149 | + | pub member_only: bool, | |
| 145 | 150 | } | |
| 146 | 151 | ||
| 147 | 152 | /// `create`. Returns `Outcome<Repo>`. | |
| 148 | 153 | #[derive(Debug, Serialize, Deserialize)] | |
| 149 | 154 | #[serde(rename_all = "camelCase")] | |
| 150 | 155 | pub struct CreateArgs { | |
| 156 | + | /// Who is creating it; they must belong to the workspace. | |
| 151 | 157 | pub owner: User, | |
| 158 | + | /// The workspace it is created in. | |
| 159 | + | pub namespace: String, | |
| 152 | 160 | pub name: String, | |
| 153 | 161 | #[serde(default)] | |
| 154 | 162 | pub description: Option<String>, |
| 45 | 45 | deviceResolve: (userCode, user, approve) => | |
| 46 | 46 | call("device_resolve", { userCode, user, approve }), | |
| 47 | 47 | deviceClaim: (deviceCode) => call("device_claim", { deviceCode }), | |
| 48 | + | createWorkspace: (user, slug, name) => call("create_workspace", { user, slug, name }), | |
| 49 | + | getWorkspace: (slug) => call("get_workspace", { slug }), | |
| 50 | + | listMembers: (slug, viewer) => call("list_members", { slug, viewer }), | |
| 51 | + | addMember: (actor, slug, username) => call("add_member", { actor, slug, username }), | |
| 52 | + | removeMember: (actor, slug, username) => | |
| 53 | + | call("remove_member", { actor, slug, username }), | |
| 48 | 54 | userForSession: (sessionToken) => call("user_for_session", { sessionToken }), | |
| 49 | 55 | userForGitCredentials: (username, secret) => | |
| 50 | 56 | call("user_for_git_credentials", { username, secret }), |
| 8 | 8 | * resolved from credentials; unverified accounts cannot change anything. | |
| 9 | 9 | */ | |
| 10 | 10 | verified?: boolean; | |
| 11 | + | /** | |
| 12 | + | * The workspaces this user belongs to. Set on users resolved from | |
| 13 | + | * credentials, so any service can authorize from it. | |
| 14 | + | */ | |
| 15 | + | workspaces?: Membership[]; | |
| 11 | 16 | }; | |
| 12 | 17 | ||
| 18 | + | /** What a member may do: an owner also manages the workspace's members. */ | |
| 19 | + | export type Role = "owner" | "member"; | |
| 20 | + | ||
| 21 | + | export type Membership = { slug: string; role: Role }; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * A workspace: the owner of repositories, and the first segment of their | |
| 25 | + | * URLs. A person's own space and a team's are the same thing. | |
| 26 | + | */ | |
| 27 | + | export type Workspace = { | |
| 28 | + | id: string; | |
| 29 | + | slug: string; | |
| 30 | + | name: string; | |
| 31 | + | /** RFC 3339. */ | |
| 32 | + | createdAt: string; | |
| 33 | + | memberCount: number; | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | export type Member = { username: string; role: Role }; | |
| 37 | + | ||
| 13 | 38 | /** Who is asking. Every read and write in every service takes one. */ | |
| 14 | 39 | export type Viewer = User | null; | |
| 15 | 40 | ||
| 67 | 92 | deviceResolve(userCode: string, user: User, approve: boolean): Promise<Result<boolean>>; | |
| 68 | 93 | deviceClaim(deviceCode: string): Promise<DeviceClaim>; | |
| 69 | 94 | ||
| 95 | + | createWorkspace(user: User, slug: string, name: string): Promise<Result<Workspace>>; | |
| 96 | + | /** Public details of a workspace, or null. */ | |
| 97 | + | getWorkspace(slug: string): Promise<Workspace | null>; | |
| 98 | + | /** Members only. */ | |
| 99 | + | listMembers(slug: string, viewer: Viewer): Promise<Result<Member[]>>; | |
| 100 | + | /** Owners only. */ | |
| 101 | + | addMember(actor: User, slug: string, username: string): Promise<Result<boolean>>; | |
| 102 | + | /** Owners only. */ | |
| 103 | + | removeMember(actor: User, slug: string, username: string): Promise<Result<boolean>>; | |
| 104 | + | ||
| 70 | 105 | userForSession(sessionToken: string): Promise<Viewer>; | |
| 71 | 106 | ||
| 72 | 107 | /** Verifies git credentials: the account password or an access token. */ |
| 6 | 6 | /** Routes and reserved words that may not be registered as usernames. */ | |
| 7 | 7 | const RESERVED = new Set([ | |
| 8 | 8 | "api", "mcp", "login", "logout", "register", "new", "settings", "search", | |
| 9 | − | "admin", "auth", "attempts", "verify", "forgot", "reset", "device", "oauth", "assets", "docs", "explore", "g1t", "about", | |
| 9 | + | "admin", "auth", "attempts", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "docs", "explore", "g1t", "about", | |
| 10 | 10 | ]); | |
| 11 | 11 | ||
| 12 | 12 | export function isValidNamespace(value: string): boolean { |
| 3 | 3 | ||
| 4 | 4 | export type Repo = { | |
| 5 | 5 | id: string; | |
| 6 | − | /** The owning user's (later, workspace's) name: the first URL segment. */ | |
| 6 | + | /** The slug of the workspace that owns it: the first URL segment. */ | |
| 7 | 7 | namespace: string; | |
| 8 | 8 | name: string; | |
| 9 | 9 | description: string | null; | |
| 59 | 59 | export type GitService = "git-upload-pack" | "git-receive-pack"; | |
| 60 | 60 | ||
| 61 | 61 | export type CreateRepoInput = { | |
| 62 | + | /** The workspace to create it in; the creator must be a member. */ | |
| 63 | + | namespace: string; | |
| 62 | 64 | name: string; | |
| 63 | 65 | description?: string | null; | |
| 64 | 66 | isPrivate?: boolean; | |
| 69 | 71 | get(path: RepoPath, viewer: Viewer): Promise<Result<Repo>>; | |
| 70 | 72 | getById(id: string, viewer: Viewer): Promise<Result<Repo>>; | |
| 71 | 73 | /** Repos the viewer may see, newest first, optionally matching `query`. */ | |
| 72 | − | list(viewer: Viewer, options?: { query?: string; namespace?: string }): Promise<Repo[]>; | |
| 74 | + | list( | |
| 75 | + | viewer: Viewer, | |
| 76 | + | options?: { | |
| 77 | + | query?: string; | |
| 78 | + | /** Only repos in this workspace. */ | |
| 79 | + | namespace?: string; | |
| 80 | + | /** Only repos in workspaces the viewer belongs to. */ | |
| 81 | + | memberOnly?: boolean; | |
| 82 | + | }, | |
| 83 | + | ): Promise<Repo[]>; | |
| 73 | 84 | create(owner: User, input: CreateRepoInput): Promise<Result<Repo>>; | |
| 74 | 85 | ||
| 75 | 86 | tree(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<Result<TreeView>>; |
| 1 | + | -- Workspaces own repositories and are the first segment of their URLs. | |
| 2 | + | CREATE TABLE workspaces ( | |
| 3 | + | id TEXT PRIMARY KEY, | |
| 4 | + | slug TEXT NOT NULL UNIQUE, | |
| 5 | + | name TEXT NOT NULL, | |
| 6 | + | created_by TEXT NOT NULL REFERENCES users (id), | |
| 7 | + | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 8 | + | ); | |
| 9 | + | ||
| 10 | + | CREATE TABLE workspace_members ( | |
| 11 | + | workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, | |
| 12 | + | user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, | |
| 13 | + | -- 'owner' or 'member' | |
| 14 | + | role TEXT NOT NULL, | |
| 15 | + | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 16 | + | PRIMARY KEY (workspace_id, user_id) | |
| 17 | + | ); | |
| 18 | + | CREATE INDEX workspace_members_user ON workspace_members (user_id); | |
| 19 | + | ||
| 20 | + | -- Repositories created before workspaces existed live under their owner's | |
| 21 | + | -- username, so each existing account gets a workspace of that name. | |
| 22 | + | INSERT INTO workspaces (id, slug, name, created_by) | |
| 23 | + | SELECT 'wsp_' || lower(hex(randomblob(13))), username, username, id FROM users; | |
| 24 | + | ||
| 25 | + | INSERT INTO workspace_members (workspace_id, user_id, role) | |
| 26 | + | SELECT id, created_by, 'owner' FROM workspaces; |
| 6 | 6 | mod crypto; | |
| 7 | 7 | mod device; | |
| 8 | 8 | mod email; | |
| 9 | + | mod workspaces; | |
| 9 | 10 | ||
| 10 | 11 | use g1t_contracts::identity::*; | |
| 11 | 12 | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; | |
| 36 | 37 | id: row.id, | |
| 37 | 38 | username: row.username, | |
| 38 | 39 | verified: row.verified != 0, | |
| 40 | + | workspaces: Vec::new(), | |
| 39 | 41 | } | |
| 40 | 42 | } | |
| 41 | 43 | } | |
| 98 | 100 | } | |
| 99 | 101 | ||
| 100 | 102 | impl Identity { | |
| 101 | − | /// Runs a query that returns at most one user. | |
| 102 | − | async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> { | |
| 103 | + | /// Runs a query that returns at most one user, for showing to others: | |
| 104 | + | /// without their workspaces. | |
| 105 | + | async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> { | |
| 103 | 106 | Ok(self | |
| 104 | 107 | .db | |
| 105 | 108 | .prepare(sql) | |
| 109 | 112 | .map(User::from)) | |
| 110 | 113 | } | |
| 111 | 114 | ||
| 115 | + | /// Attaches the workspaces a user belongs to, so that any service can | |
| 116 | + | /// authorize them without asking again. | |
| 117 | + | async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> { | |
| 118 | + | let Some(mut user) = user else { | |
| 119 | + | return Ok(None); | |
| 120 | + | }; | |
| 121 | + | user.workspaces = self.memberships(&user.id).await?; | |
| 122 | + | Ok(Some(user)) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// Runs a query that resolves credentials to at most one user. | |
| 126 | + | async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> { | |
| 127 | + | let user = self.find_public_user(sql, param).await?; | |
| 128 | + | self.with_workspaces(user).await | |
| 129 | + | } | |
| 130 | + | ||
| 112 | 131 | /// Stores a one-time token of `kind` for the user and returns it. | |
| 113 | 132 | async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u64) -> Result<String> { | |
| 114 | 133 | let token = crypto::random_hex(32); | |
| 200 | 219 | id: owner.id, | |
| 201 | 220 | username: owner.username, | |
| 202 | 221 | verified: true, | |
| 222 | + | ..User::default() | |
| 203 | 223 | })) | |
| 204 | 224 | } | |
| 205 | 225 | ||
| 258 | 278 | id: owner.id, | |
| 259 | 279 | username: owner.username, | |
| 260 | 280 | verified: true, | |
| 281 | + | ..User::default() | |
| 261 | 282 | })) | |
| 262 | 283 | } | |
| 263 | 284 | ||
| 268 | 289 | .bind(&[JsValue::from(username.to_lowercase())])? | |
| 269 | 290 | .first::<UserRow>(None) | |
| 270 | 291 | .await?; | |
| 271 | − | Ok(row | |
| 292 | + | let user = row | |
| 272 | 293 | .filter(|row| crypto::verify_password(password, &row.password_hash)) | |
| 273 | 294 | .map(|row| User { | |
| 274 | 295 | id: row.id, | |
| 275 | 296 | username: row.username, | |
| 276 | 297 | verified: row.verified != 0, | |
| 277 | − | })) | |
| 298 | + | ..User::default() | |
| 299 | + | }); | |
| 300 | + | self.with_workspaces(user).await | |
| 278 | 301 | } | |
| 279 | 302 | ||
| 280 | 303 | async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> { | |
| 311 | 334 | let user = User { | |
| 312 | 335 | id: new_id("usr", now_ms()), | |
| 313 | 336 | username, | |
| 314 | − | verified: false, | |
| 337 | + | ..User::default() | |
| 315 | 338 | }; | |
| 316 | 339 | self.db | |
| 317 | 340 | .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)") | |
| 416 | 439 | } | |
| 417 | 440 | ||
| 418 | 441 | async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> { | |
| 419 | − | self.find_user( | |
| 442 | + | self.find_public_user( | |
| 420 | 443 | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?", | |
| 421 | 444 | &a.username.to_lowercase(), | |
| 422 | 445 | ) | |
| 557 | 580 | match method.as_str() { | |
| 558 | 581 | "register" => reply(&identity.register(args(body)?).await?), | |
| 559 | 582 | "sign_in" => reply(&identity.sign_in(args(body)?).await?), | |
| 583 | + | "create_workspace" => reply(&identity.create_workspace(args(body)?).await?), | |
| 584 | + | "get_workspace" => reply(&identity.get_workspace(args(body)?).await?), | |
| 585 | + | "list_members" => reply(&identity.list_members(args(body)?).await?), | |
| 586 | + | "add_member" => reply(&identity.add_member(args(body)?).await?), | |
| 587 | + | "remove_member" => reply(&identity.remove_member(args(body)?).await?), | |
| 560 | 588 | "device_start" => reply(&identity.device_start(args(body)?).await?), | |
| 561 | 589 | "device_lookup" => reply(&identity.device_lookup(args(body)?).await?), | |
| 562 | 590 | "device_resolve" => reply(&identity.device_resolve(args(body)?).await?), |
| 1 | + | //! Workspaces and their members. | |
| 2 | + | //! | |
| 3 | + | //! A workspace owns repositories and is the first segment of their URLs. | |
| 4 | + | //! There is one kind: a person's own space and a company's differ only in | |
| 5 | + | //! how many members they have. Nothing can be created outside one. | |
| 6 | + | ||
| 7 | + | use g1t_contracts::identity::*; | |
| 8 | + | use g1t_contracts::time::rfc3339; | |
| 9 | + | use g1t_contracts::{FailureCode, Membership, Outcome, Role, User, is_valid_namespace, new_id}; | |
| 10 | + | use g1t_kit::now_ms; | |
| 11 | + | use serde::Deserialize; | |
| 12 | + | use worker::Result; | |
| 13 | + | ||
| 14 | + | use crate::Identity; | |
| 15 | + | ||
| 16 | + | /// Enough for a person and their teams; stops one account claiming names in | |
| 17 | + | /// bulk. | |
| 18 | + | const MAX_WORKSPACES_PER_USER: usize = 10; | |
| 19 | + | ||
| 20 | + | const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name, | |
| 21 | + | workspaces.created_at, | |
| 22 | + | (SELECT count(*) FROM workspace_members | |
| 23 | + | WHERE workspace_members.workspace_id = workspaces.id) AS member_count"; | |
| 24 | + | ||
| 25 | + | #[derive(Deserialize)] | |
| 26 | + | struct WorkspaceRow { | |
| 27 | + | id: String, | |
| 28 | + | slug: String, | |
| 29 | + | name: String, | |
| 30 | + | created_at: String, | |
| 31 | + | member_count: u32, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl From<WorkspaceRow> for Workspace { | |
| 35 | + | fn from(row: WorkspaceRow) -> Self { | |
| 36 | + | Workspace { | |
| 37 | + | id: row.id, | |
| 38 | + | slug: row.slug, | |
| 39 | + | name: row.name, | |
| 40 | + | created_at: row.created_at, | |
| 41 | + | member_count: row.member_count, | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | #[derive(Deserialize)] | |
| 47 | + | struct MemberRow { | |
| 48 | + | username: String, | |
| 49 | + | role: Role, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | impl Identity { | |
| 53 | + | /// The workspaces a user belongs to, attached to every user resolved | |
| 54 | + | /// from credentials. | |
| 55 | + | pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> { | |
| 56 | + | self.db | |
| 57 | + | .prepare( | |
| 58 | + | "SELECT workspaces.slug, workspace_members.role FROM workspace_members | |
| 59 | + | JOIN workspaces ON workspaces.id = workspace_members.workspace_id | |
| 60 | + | WHERE workspace_members.user_id = ? ORDER BY workspaces.slug", | |
| 61 | + | ) | |
| 62 | + | .bind(&[user_id.into()])? | |
| 63 | + | .all() | |
| 64 | + | .await? | |
| 65 | + | .results::<Membership>() | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> { | |
| 69 | + | if !a.user.verified { | |
| 70 | + | return Ok(Outcome::fail( | |
| 71 | + | FailureCode::Forbidden, | |
| 72 | + | "Confirm your email address before creating a workspace.", | |
| 73 | + | )); | |
| 74 | + | } | |
| 75 | + | let slug = a.slug.trim().to_lowercase(); | |
| 76 | + | if !is_valid_namespace(&slug) { | |
| 77 | + | return Ok(Outcome::fail( | |
| 78 | + | FailureCode::Invalid, | |
| 79 | + | "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters.", | |
| 80 | + | )); | |
| 81 | + | } | |
| 82 | + | if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER { | |
| 83 | + | return Ok(Outcome::fail( | |
| 84 | + | FailureCode::Conflict, | |
| 85 | + | "You belong to the maximum number of workspaces.", | |
| 86 | + | )); | |
| 87 | + | } | |
| 88 | + | if self | |
| 89 | + | .get_workspace(SlugArgs { slug: slug.clone() }) | |
| 90 | + | .await? | |
| 91 | + | .is_some() | |
| 92 | + | { | |
| 93 | + | return Ok(Outcome::fail( | |
| 94 | + | FailureCode::Conflict, | |
| 95 | + | "That workspace name is taken.", | |
| 96 | + | )); | |
| 97 | + | } | |
| 98 | + | let now = now_ms(); | |
| 99 | + | let workspace = Workspace { | |
| 100 | + | id: new_id("wsp", now), | |
| 101 | + | name: match a.name.trim() { | |
| 102 | + | "" => slug.clone(), | |
| 103 | + | name => name.chars().take(80).collect(), | |
| 104 | + | }, | |
| 105 | + | slug, | |
| 106 | + | created_at: rfc3339(now), | |
| 107 | + | member_count: 1, | |
| 108 | + | }; | |
| 109 | + | self.db | |
| 110 | + | .batch(vec![ | |
| 111 | + | self.db | |
| 112 | + | .prepare( | |
| 113 | + | "INSERT INTO workspaces (id, slug, name, created_by, created_at) | |
| 114 | + | VALUES (?, ?, ?, ?, ?)", | |
| 115 | + | ) | |
| 116 | + | .bind(&[ | |
| 117 | + | workspace.id.as_str().into(), | |
| 118 | + | workspace.slug.as_str().into(), | |
| 119 | + | workspace.name.as_str().into(), | |
| 120 | + | a.user.id.as_str().into(), | |
| 121 | + | workspace.created_at.as_str().into(), | |
| 122 | + | ])?, | |
| 123 | + | self.db | |
| 124 | + | .prepare( | |
| 125 | + | "INSERT INTO workspace_members (workspace_id, user_id, role, created_at) | |
| 126 | + | VALUES (?, ?, 'owner', ?)", | |
| 127 | + | ) | |
| 128 | + | .bind(&[ | |
| 129 | + | workspace.id.as_str().into(), | |
| 130 | + | a.user.id.as_str().into(), | |
| 131 | + | workspace.created_at.as_str().into(), | |
| 132 | + | ])?, | |
| 133 | + | ]) | |
| 134 | + | .await?; | |
| 135 | + | Ok(Outcome::Ok(workspace)) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> { | |
| 139 | + | Ok(self | |
| 140 | + | .db | |
| 141 | + | .prepare(format!( | |
| 142 | + | "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ?" | |
| 143 | + | )) | |
| 144 | + | .bind(&[a.slug.to_lowercase().into()])? | |
| 145 | + | .first::<WorkspaceRow>(None) | |
| 146 | + | .await? | |
| 147 | + | .map(Workspace::from)) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> { | |
| 151 | + | let slug = a.slug.to_lowercase(); | |
| 152 | + | if !a.viewer.is_some_and(|viewer| viewer.is_member(&slug)) { | |
| 153 | + | return Ok(Outcome::fail( | |
| 154 | + | FailureCode::Forbidden, | |
| 155 | + | "Only members can see who is in a workspace.", | |
| 156 | + | )); | |
| 157 | + | } | |
| 158 | + | let rows = self | |
| 159 | + | .db | |
| 160 | + | .prepare( | |
| 161 | + | "SELECT users.username, workspace_members.role FROM workspace_members | |
| 162 | + | JOIN users ON users.id = workspace_members.user_id | |
| 163 | + | JOIN workspaces ON workspaces.id = workspace_members.workspace_id | |
| 164 | + | WHERE workspaces.slug = ? | |
| 165 | + | ORDER BY workspace_members.role DESC, users.username", | |
| 166 | + | ) | |
| 167 | + | .bind(&[slug.into()])? | |
| 168 | + | .all() | |
| 169 | + | .await? | |
| 170 | + | .results::<MemberRow>()?; | |
| 171 | + | Ok(Outcome::Ok( | |
| 172 | + | rows.into_iter() | |
| 173 | + | .map(|row| Member { | |
| 174 | + | username: row.username, | |
| 175 | + | role: row.role, | |
| 176 | + | }) | |
| 177 | + | .collect(), | |
| 178 | + | )) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// The ids needed to change a workspace's members, if `actor` owns it | |
| 182 | + | /// and `username` exists. | |
| 183 | + | async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> { | |
| 184 | + | let slug = a.slug.to_lowercase(); | |
| 185 | + | if a.actor.role_in(&slug) != Some(Role::Owner) { | |
| 186 | + | return Ok(Outcome::fail( | |
| 187 | + | FailureCode::Forbidden, | |
| 188 | + | "Only an owner can change a workspace's members.", | |
| 189 | + | )); | |
| 190 | + | } | |
| 191 | + | let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else { | |
| 192 | + | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 193 | + | }; | |
| 194 | + | let Some(user) = self | |
| 195 | + | .find_public_user( | |
| 196 | + | "SELECT id, username, email_verified_at IS NOT NULL AS verified | |
| 197 | + | FROM users WHERE username = ?", | |
| 198 | + | &a.username.trim().to_lowercase(), | |
| 199 | + | ) | |
| 200 | + | .await? | |
| 201 | + | else { | |
| 202 | + | return Ok(Outcome::fail( | |
| 203 | + | FailureCode::NotFound, | |
| 204 | + | "There is no account with that username.", | |
| 205 | + | )); | |
| 206 | + | }; | |
| 207 | + | Ok(Outcome::Ok((workspace.id, user))) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> { | |
| 211 | + | let (workspace_id, user) = match self.member_target(&a).await? { | |
| 212 | + | Outcome::Ok(target) => target, | |
| 213 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 214 | + | }; | |
| 215 | + | self.db | |
| 216 | + | .prepare( | |
| 217 | + | "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at) | |
| 218 | + | VALUES (?, ?, 'member', ?)", | |
| 219 | + | ) | |
| 220 | + | .bind(&[ | |
| 221 | + | workspace_id.into(), | |
| 222 | + | user.id.into(), | |
| 223 | + | rfc3339(now_ms()).into(), | |
| 224 | + | ])? | |
| 225 | + | .run() | |
| 226 | + | .await?; | |
| 227 | + | Ok(Outcome::Ok(true)) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> { | |
| 231 | + | let (workspace_id, user) = match self.member_target(&a).await? { | |
| 232 | + | Outcome::Ok(target) => target, | |
| 233 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 234 | + | }; | |
| 235 | + | if user.id == a.actor.id { | |
| 236 | + | return Ok(Outcome::fail( | |
| 237 | + | FailureCode::Conflict, | |
| 238 | + | "An owner cannot remove themselves.", | |
| 239 | + | )); | |
| 240 | + | } | |
| 241 | + | self.db | |
| 242 | + | .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?") | |
| 243 | + | .bind(&[workspace_id.into(), user.id.into()])? | |
| 244 | + | .run() | |
| 245 | + | .await?; | |
| 246 | + | Ok(Outcome::Ok(true)) | |
| 247 | + | } | |
| 248 | + | } |
| 14 | 14 | use g1t_contracts::events::{GitPush, NewEvent, RepoCreated, RepoForked}; | |
| 15 | 15 | use g1t_contracts::repos::*; | |
| 16 | 16 | use g1t_contracts::time::rfc3339; | |
| 17 | − | use g1t_contracts::{ | |
| 18 | − | FailureCode, Outcome, User, Viewer, is_valid_namespace, is_valid_repo_name, new_id, | |
| 19 | − | }; | |
| 17 | + | use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_repo_name, new_id}; | |
| 20 | 18 | use g1t_kit::{args, js, now_ms, reply, rpc_method}; | |
| 21 | 19 | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 22 | 20 | ||
| 166 | 164 | "Use letters, digits, dots, hyphens and underscores only.", | |
| 167 | 165 | )); | |
| 168 | 166 | } | |
| 169 | − | if !is_valid_namespace(&a.owner.username) { | |
| 167 | + | let namespace = a.namespace.trim().to_lowercase(); | |
| 168 | + | if namespace.is_empty() { | |
| 170 | 169 | return Ok(Outcome::fail( | |
| 171 | 170 | FailureCode::Invalid, | |
| 172 | − | "This account cannot own repositories.", | |
| 171 | + | "Say which workspace to create the repository in.", | |
| 172 | + | )); | |
| 173 | + | } | |
| 174 | + | if !a.owner.is_member(&namespace) { | |
| 175 | + | return Ok(Outcome::fail( | |
| 176 | + | FailureCode::Forbidden, | |
| 177 | + | "You are not a member of that workspace.", | |
| 173 | 178 | )); | |
| 174 | 179 | } | |
| 175 | − | let path = RepoPath { | |
| 176 | − | namespace: a.owner.username.clone(), | |
| 177 | − | name, | |
| 178 | − | }; | |
| 180 | + | let path = RepoPath { namespace, name }; | |
| 179 | 181 | if self.registry.by_path(&path).await?.is_some() { | |
| 180 | 182 | return Ok(Outcome::fail( | |
| 181 | 183 | FailureCode::Conflict, | |
| 182 | − | "You already have a repository with that name.", | |
| 184 | + | "That workspace already has a repository with that name.", | |
| 183 | 185 | )); | |
| 184 | 186 | } | |
| 185 | 187 | let now = now_ms(); | |
| 394 | 396 | repo | |
| 395 | 397 | } | |
| 396 | 398 | None => { | |
| 397 | − | // Push to create, in the pusher's own namespace only. | |
| 399 | + | // Push to create, in a workspace the pusher belongs to. | |
| 398 | 400 | let owner = a | |
| 399 | 401 | .viewer | |
| 400 | 402 | .as_ref() | |
| 401 | − | .filter(|user| write && user.username == a.path.namespace.to_lowercase()); | |
| 403 | + | .filter(|user| write && user.is_member(&a.path.namespace.to_lowercase())); | |
| 402 | 404 | let Some(owner) = owner else { | |
| 403 | 405 | return Ok(denied()); | |
| 404 | 406 | }; | |
| 405 | 407 | let created = self | |
| 406 | 408 | .create(CreateArgs { | |
| 407 | 409 | owner: owner.clone(), | |
| 410 | + | namespace: a.path.namespace.clone(), | |
| 408 | 411 | name: a.path.name.clone(), | |
| 409 | 412 | description: None, | |
| 410 | 413 | is_private: false, | |
| 436 | 439 | if !can_write(&target, &actor) { | |
| 437 | 440 | return Ok(Outcome::fail( | |
| 438 | 441 | FailureCode::Forbidden, | |
| 439 | − | "Only the repository's owner can land an attempt.", | |
| 442 | + | "Only members of the repository's workspace can land an attempt.", | |
| 440 | 443 | )); | |
| 441 | 444 | } | |
| 442 | 445 | if !a.actor.verified { | |
| 627 | 630 | reply( | |
| 628 | 631 | &repos | |
| 629 | 632 | .registry | |
| 630 | − | .list(&a.viewer, a.query.as_deref(), a.namespace.as_deref()) | |
| 633 | + | .list( | |
| 634 | + | &a.viewer, | |
| 635 | + | a.query.as_deref(), | |
| 636 | + | a.namespace.as_deref(), | |
| 637 | + | a.member_only, | |
| 638 | + | ) | |
| 631 | 639 | .await?, | |
| 632 | 640 | ) | |
| 633 | 641 | } |
| 44 | 44 | !repo.is_private || can_write(repo, viewer) | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | /// A repository belongs to its workspace, so any member may write to it. An | |
| 48 | + | /// attempt's fork belongs to whoever started the attempt. | |
| 47 | 49 | pub fn can_write(repo: &Repo, viewer: &Viewer) -> bool { | |
| 48 | − | viewer.as_ref().is_some_and(|user| user.id == repo.owner_id) | |
| 50 | + | viewer.as_ref().is_some_and(|user| { | |
| 51 | + | if repo.fork_of.is_some() { | |
| 52 | + | user.id == repo.owner_id | |
| 53 | + | } else { | |
| 54 | + | user.is_member(&repo.namespace) | |
| 55 | + | } | |
| 56 | + | }) | |
| 49 | 57 | } | |
| 50 | 58 | ||
| 51 | 59 | fn optional(value: &Option<String>) -> JsValue { | |
| 81 | 89 | } | |
| 82 | 90 | ||
| 83 | 91 | /// Repos the viewer may see, newest first. Excludes attempt forks. | |
| 92 | + | /// With `member_only`, only repos in the viewer's own workspaces. | |
| 84 | 93 | pub async fn list( | |
| 85 | 94 | &self, | |
| 86 | 95 | viewer: &Viewer, | |
| 87 | 96 | query: Option<&str>, | |
| 88 | 97 | namespace: Option<&str>, | |
| 98 | + | member_only: bool, | |
| 89 | 99 | ) -> Result<Vec<Repo>> { | |
| 90 | − | let mut conditions = vec!["fork_of IS NULL", "(is_private = 0 OR owner_id = ?)"]; | |
| 91 | − | let viewer_id = viewer.as_ref().map_or("", |user| user.id.as_str()); | |
| 92 | − | let mut params: Vec<JsValue> = vec![viewer_id.into()]; | |
| 100 | + | let workspaces: Vec<&str> = viewer | |
| 101 | + | .iter() | |
| 102 | + | .flat_map(|user| &user.workspaces) | |
| 103 | + | .map(|membership| membership.slug.as_str()) | |
| 104 | + | .collect(); | |
| 105 | + | // An empty IN list is not valid SQL, so a viewer in no workspace | |
| 106 | + | // gets a name no workspace can have. | |
| 107 | + | let mut params: Vec<JsValue> = if workspaces.is_empty() { | |
| 108 | + | vec!["".into()] | |
| 109 | + | } else { | |
| 110 | + | workspaces.iter().map(|slug| JsValue::from(*slug)).collect() | |
| 111 | + | }; | |
| 112 | + | let mine = format!("namespace IN ({})", vec!["?"; params.len()].join(", ")); | |
| 113 | + | let mut conditions = vec![ | |
| 114 | + | "fork_of IS NULL".to_owned(), | |
| 115 | + | if member_only { | |
| 116 | + | mine | |
| 117 | + | } else { | |
| 118 | + | format!("(is_private = 0 OR {mine})") | |
| 119 | + | }, | |
| 120 | + | ]; | |
| 93 | 121 | if let Some(namespace) = namespace { | |
| 94 | − | conditions.push("namespace = ?"); | |
| 122 | + | conditions.push("namespace = ?".to_owned()); | |
| 95 | 123 | params.push(namespace.to_lowercase().into()); | |
| 96 | 124 | } | |
| 97 | 125 | if let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) { | |
| 98 | − | conditions.push("(name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')"); | |
| 126 | + | conditions | |
| 127 | + | .push("(name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')".to_owned()); | |
| 99 | 128 | // LIKE wildcards in the query are matched literally. | |
| 100 | 129 | let escaped: String = query | |
| 101 | 130 | .chars() |
| 260 | 260 | Outcome::Ok(repo) => repo, | |
| 261 | 261 | Outcome::Fail(_) => return Ok(no_intent()), | |
| 262 | 262 | }; | |
| 263 | − | if intent.author.id != a.actor.id && repo.owner_id != a.actor.id { | |
| 263 | + | if intent.author.id != a.actor.id && !a.actor.is_member(&repo.namespace) { | |
| 264 | 264 | return Ok(Outcome::fail( | |
| 265 | 265 | FailureCode::Forbidden, | |
| 266 | − | "Only the author or the repo owner can withdraw an intent.", | |
| 266 | + | "Only the author or a member of the workspace can withdraw an intent.", | |
| 267 | 267 | )); | |
| 268 | 268 | } | |
| 269 | 269 | if intent.status != IntentStatus::Open { |
| 36 | 36 | author: User { | |
| 37 | 37 | id: row.author_id, | |
| 38 | 38 | username: row.author_name, | |
| 39 | − | verified: false, | |
| 39 | + | ..User::default() | |
| 40 | 40 | }, | |
| 41 | 41 | created_at: row.created_at, | |
| 42 | 42 | attempt_count: row.attempt_count, | |
| 86 | 86 | started_by: User { | |
| 87 | 87 | id: row.started_by_id, | |
| 88 | 88 | username: row.started_by_name, | |
| 89 | − | verified: false, | |
| 89 | + | ..User::default() | |
| 90 | 90 | }, | |
| 91 | 91 | created_at: row.created_at, | |
| 92 | 92 | updated_at: row.updated_at, |