Commit

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

syntaqxcommitted Parentefd1509Browse files
30 files+879−1110/30 viewed
+2−1
3434 input?: (params: Record<string, string>, query: Input, body: Input) => Input;
3535 }[] = [
3636 { method: "GET", path: "/v1/user", operation: "whoami" },
37+ { method: "POST", path: "/v1/workspaces", operation: "create_workspace", input: (_p, _q, b) => b },
3738 { method: "GET", path: "/v1/repos", operation: "list_repos", input: (_p, q) => ({ query: q.q }) },
3839 { method: "POST", path: "/v1/repos", operation: "create_repo", input: (_p, _q, b) => b },
3940 { method: "GET", path: "/v1/repos/:owner/:name", operation: "get_repo", input: repo },
5758
5859 /** The section of the API reference an operation is listed under. */
5960 function tagFor(operation: string): string {
60− if (operation === "whoami") return "Accounts";
61+ if (operation === "whoami" || operation.includes("workspace")) return "Accounts";
6162 if (operation.includes("session")) return "Sessions";
6263 if (operation.includes("attempt")) return "Attempts";
6364 if (operation.includes("intent")) return "Intents";
+29−2
6868 export const operations: Operation[] = [
6969 {
7070 name: "whoami",
71− description: "The account the access token belongs to.",
71+ description: "The account the access token belongs to, and its workspaces.",
7272 input: { type: "object", properties: {} },
7373 run: authed(async (_env, user) => ok(user)),
7474 },
7575 {
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+ {
7695 name: "list_repos",
7796 description: "Repositories you can see, optionally filtered by a search query.",
7897 input: {
93112 },
94113 {
95114 name: "create_repo",
96− description: "Create a repository under your account.",
115+ description: "Create a repository in one of your workspaces.",
97116 input: {
98117 type: "object",
99118 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+ },
100124 name: { type: "string" },
101125 description: { type: "string" },
102126 private: { type: "boolean" },
105129 },
106130 run: authed((env, user, input) =>
107131 env.REPOS.create(user, {
132+ namespace:
133+ text(input, "workspace") ||
134+ (user.workspaces?.length === 1 ? user.workspaces[0].slug : ""),
108135 name: text(input, "name"),
109136 description: text(input, "description"),
110137 isPrivate: input.private === true,
+1−1
5252
5353 ## Shipping
5454
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`
5656 to the attempt's head commit, marks the attempt `shipped` and closes the
5757 intent.
5858
+19−2
66 ## Creating an account
77
88 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.
1110
1211 Accounts can only be created in a browser. There is no API for it, by
1312 design: it keeps passwords out of scripts and agents, and lets g1t protect
2221 message telling you to confirm your address. To get a new link, sign in and
2322 use the banner at the top of the site.
2423
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+
2542 ## Access tokens
2643
2744 A token stands in for your password everywhere outside the website:
+6−5
88 ## Remotes
99
1010 ```text
11−https://g1t.sh/<owner>/<repo>.git
11+https://g1t.sh/<workspace>/<repo>.git
1212 ```
1313
1414 Public repositories can be cloned without signing in:
3232
3333 ## Creating a repository by pushing
3434
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.
3737
3838 ```sh
39−git push https://g1t.sh/<username>/new-repo.git main
39+git push https://g1t.sh/<workspace>/new-repo.git main
4040 ```
4141
4242 ## Private repositories
4343
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
4546 exactly like a repository that does not exist, both on the site and to git.
4647
4748 ## Attempt forks
+9−5
1515
1616 ## 1. Create an account
1717
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.
2024
2125 ## 2. Create an access token
2226
3539
3640 ```sh
3741 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
3943 git push -u g1t main
4044 ```
4145
4246 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.
4448
4549 ## 4. Open an intent
4650
6165
6266 Then ask it to work on the intent:
6367
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
6569 > on the first one, and record your session as you go.
6670
6771 The agent gets its own fork of the repository, pushes its commits there, and
+13−4
11 import {
22 BookOpen,
3+ Building2,
34 ChevronDown,
45 LayoutDashboard,
56 LogOut,
67 Plus,
78 Search,
89 Settings,
9− UserRound,
1010 } from "lucide-react";
1111 import {
1212 Form,
121121 </span>
122122 </DropdownMenuLabel>
123123 <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+ ))}
124132 <DropdownMenuItem asChild>
125− <Link to={`/${user.username}`}>
126− <UserRound />
127− Your profile
133+ <Link to="/workspaces/new">
134+ <Plus />
135+ New workspace
128136 </Link>
129137 </DropdownMenuItem>
138+ <DropdownMenuSeparator />
130139 <DropdownMenuItem asChild>
131140 <Link to="/">
132141 <LayoutDashboard />
+2−1
1313 route("settings", "routes/settings.tsx"),
1414 route("explore", "routes/explore.tsx", { id: "explore" }),
1515 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"),
1718 route(":owner/:repo", "routes/repo/layout.tsx", [
1819 index("routes/repo/code.tsx"),
1920 route("tree/:ref/*", "routes/repo/tree.tsx"),
+6−2
11 import { ArrowRight, Plus } from "lucide-react";
2−import { Link } from "react-router";
2+import { Link, redirect } from "react-router";
33
44 import type { Route } from "./+types/home";
55 import { Landing } from "../components/landing";
2828
2929 export async function loader({ context }: Route.LoaderArgs) {
3030 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+ }
3135 const [repos, attempts] = await Promise.all([
32− reposApi.list(viewer, viewer ? { namespace: viewer.username } : {}),
36+ reposApi.list(viewer, { memberOnly: Boolean(viewer) }),
3337 work.listActiveAttempts(viewer),
3438 ]);
3539 // Mission control links to each attempt under its repo.
+23−2
1010 }
1111
1212 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+ };
1422 }
1523
1624 export async function action({ request, context }: Route.ActionArgs) {
1826 const user = requireUser(context, request);
1927 const form = await request.formData();
2028 const result = await repos.create(user, {
29+ namespace: String(form.get("workspace") ?? ""),
2130 name: String(form.get("name") ?? ""),
2231 description: String(form.get("description") ?? ""),
2332 isPrivate: form.get("visibility") === "private",
3645 <Form method="post" className="mt-8 space-y-4">
3746 <Field label="Name">
3847 <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>
4061 <Input name="name" required autoFocus maxLength={100} />
4162 </div>
4263 </Field>
+0−41
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−}
+6−2
6969 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
7070 session: unwrap(session),
7171 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+ ),
7478 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
7579 };
7680 }
+70−0
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+}
+128−0
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+}
+17−6
5757 --header "Authorization: Bearer $G1T_TOKEN"
5858 ```
5959
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.
6263
6364 ```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
6575 git -c credential.helper= \
6676 -c "http.extraHeader=Authorization: Basic $(printf '%s' "USERNAME:$G1T_TOKEN" | base64)" \
6777 push -u g1t main
8494 sessions are as visible as the repository.
8595 - **Submit:** `POST /v1/attempts/{attempt_id}/submit` with `summary`.
8696 - **See what an attempt changed:** `GET /v1/attempts/{attempt_id}/changes`.
87−- **Ship** (repository owner only):
97+- **Ship** (members of the repository's workspace):
8898 `POST /v1/attempts/{attempt_id}/ship`. A `409` saying main has moved means
8999 the fork is behind: pull main from `https://g1t.sh/{owner}/{name}.git` into
90100 the fork, push, and ship again.
93103 `open_intent`, `start_attempt`, `record_session`, `submit_attempt`,
94104 `get_attempt_changes`, `ship_attempt`, and `list_intents`, `get_intent`,
95105 `get_attempt`, `read_session`, `list_repos`, `get_repo`, `create_repo`,
96−`list_events`, `whoami`.
106+`list_events`, `create_workspace`, `whoami`.
97107
98108 ## Facts
99109
102112 `{"error": {"code": "…", "message": "…"}}` with codes `unauthenticated`
103113 (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid`
104114 (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:
106117 `https://g1t.sh/attempts/{attempt_id}.git`. SSH is not available.
107118 - Limits: 1 GB per repository, 32 MB per file, 100 MB per push.
108119 - Forgotten password: https://g1t.sh/forgot (the person does this, in a
+50−0
213213 user: User,
214214 },
215215 }
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+}
+35−0
1919
2020 use serde::{Deserialize, Serialize};
2121
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.
2233 #[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)]
2341 pub struct User {
2442 pub id: String,
2543 pub username: String,
2745 /// accounts can sign in but cannot create or change anything.
2846 #[serde(default)]
2947 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+ }
3065 }
3166
3267 /// Who is asking. Every read and write in every service takes one.
+30−3
11 /// Routes and reserved words that may not be registered as usernames.
22 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",
633 ];
734
835 /// Namespaces follow GitHub's rules: letters, digits and single hyphens,
+9−1
1111 #[serde(rename_all = "camelCase")]
1212 pub struct Repo {
1313 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.
1515 pub namespace: String,
1616 pub name: String,
1717 pub description: Option<String>,
136136
137137 /// `list`: repos the viewer may see, newest first. Returns `Vec<Repo>`.
138138 #[derive(Debug, Default, Serialize, Deserialize)]
139+#[serde(rename_all = "camelCase")]
139140 pub struct ListArgs {
140141 pub viewer: Viewer,
141142 #[serde(default)]
142143 pub query: Option<String>,
144+ /// Only repos in this workspace.
143145 #[serde(default)]
144146 pub namespace: Option<String>,
147+ /// Only repos in workspaces the viewer belongs to.
148+ #[serde(default)]
149+ pub member_only: bool,
145150 }
146151
147152 /// `create`. Returns `Outcome<Repo>`.
148153 #[derive(Debug, Serialize, Deserialize)]
149154 #[serde(rename_all = "camelCase")]
150155 pub struct CreateArgs {
156+ /// Who is creating it; they must belong to the workspace.
151157 pub owner: User,
158+ /// The workspace it is created in.
159+ pub namespace: String,
152160 pub name: String,
153161 #[serde(default)]
154162 pub description: Option<String>,
+6−0
4545 deviceResolve: (userCode, user, approve) =>
4646 call("device_resolve", { userCode, user, approve }),
4747 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 }),
4854 userForSession: (sessionToken) => call("user_for_session", { sessionToken }),
4955 userForGitCredentials: (username, secret) =>
5056 call("user_for_git_credentials", { username, secret }),
+35−0
88 * resolved from credentials; unverified accounts cannot change anything.
99 */
1010 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[];
1116 };
1217
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+
1338 /** Who is asking. Every read and write in every service takes one. */
1439 export type Viewer = User | null;
1540
6792 deviceResolve(userCode: string, user: User, approve: boolean): Promise<Result<boolean>>;
6893 deviceClaim(deviceCode: string): Promise<DeviceClaim>;
6994
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+
70105 userForSession(sessionToken: string): Promise<Viewer>;
71106
72107 /** Verifies git credentials: the account password or an access token. */
+1−1
66 /** Routes and reserved words that may not be registered as usernames. */
77 const RESERVED = new Set([
88 "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",
1010 ]);
1111
1212 export function isValidNamespace(value: string): boolean {
+13−2
33
44 export type Repo = {
55 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. */
77 namespace: string;
88 name: string;
99 description: string | null;
5959 export type GitService = "git-upload-pack" | "git-receive-pack";
6060
6161 export type CreateRepoInput = {
62+ /** The workspace to create it in; the creator must be a member. */
63+ namespace: string;
6264 name: string;
6365 description?: string | null;
6466 isPrivate?: boolean;
6971 get(path: RepoPath, viewer: Viewer): Promise<Result<Repo>>;
7072 getById(id: string, viewer: Viewer): Promise<Result<Repo>>;
7173 /** 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[]>;
7384 create(owner: User, input: CreateRepoInput): Promise<Result<Repo>>;
7485
7586 tree(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<Result<TreeView>>;
+26−0
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;
+34−6
66 mod crypto;
77 mod device;
88 mod email;
9+mod workspaces;
910
1011 use g1t_contracts::identity::*;
1112 use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
3637 id: row.id,
3738 username: row.username,
3839 verified: row.verified != 0,
40+ workspaces: Vec::new(),
3941 }
4042 }
4143 }
98100 }
99101
100102 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> {
103106 Ok(self
104107 .db
105108 .prepare(sql)
109112 .map(User::from))
110113 }
111114
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+
112131 /// Stores a one-time token of `kind` for the user and returns it.
113132 async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u64) -> Result<String> {
114133 let token = crypto::random_hex(32);
200219 id: owner.id,
201220 username: owner.username,
202221 verified: true,
222+ ..User::default()
203223 }))
204224 }
205225
258278 id: owner.id,
259279 username: owner.username,
260280 verified: true,
281+ ..User::default()
261282 }))
262283 }
263284
268289 .bind(&[JsValue::from(username.to_lowercase())])?
269290 .first::<UserRow>(None)
270291 .await?;
271− Ok(row
292+ let user = row
272293 .filter(|row| crypto::verify_password(password, &row.password_hash))
273294 .map(|row| User {
274295 id: row.id,
275296 username: row.username,
276297 verified: row.verified != 0,
277− }))
298+ ..User::default()
299+ });
300+ self.with_workspaces(user).await
278301 }
279302
280303 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
311334 let user = User {
312335 id: new_id("usr", now_ms()),
313336 username,
314− verified: false,
337+ ..User::default()
315338 };
316339 self.db
317340 .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)")
416439 }
417440
418441 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
419− self.find_user(
442+ self.find_public_user(
420443 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?",
421444 &a.username.to_lowercase(),
422445 )
557580 match method.as_str() {
558581 "register" => reply(&identity.register(args(body)?).await?),
559582 "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?),
560588 "device_start" => reply(&identity.device_start(args(body)?).await?),
561589 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
562590 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
+248−0
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+}
+22−14
1414 use g1t_contracts::events::{GitPush, NewEvent, RepoCreated, RepoForked};
1515 use g1t_contracts::repos::*;
1616 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};
2018 use g1t_kit::{args, js, now_ms, reply, rpc_method};
2119 use std::collections::{HashMap, HashSet, VecDeque};
2220
166164 "Use letters, digits, dots, hyphens and underscores only.",
167165 ));
168166 }
169− if !is_valid_namespace(&a.owner.username) {
167+ let namespace = a.namespace.trim().to_lowercase();
168+ if namespace.is_empty() {
170169 return Ok(Outcome::fail(
171170 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.",
173178 ));
174179 }
175− let path = RepoPath {
176− namespace: a.owner.username.clone(),
177− name,
178− };
180+ let path = RepoPath { namespace, name };
179181 if self.registry.by_path(&path).await?.is_some() {
180182 return Ok(Outcome::fail(
181183 FailureCode::Conflict,
182− "You already have a repository with that name.",
184+ "That workspace already has a repository with that name.",
183185 ));
184186 }
185187 let now = now_ms();
394396 repo
395397 }
396398 None => {
397− // Push to create, in the pusher's own namespace only.
399+ // Push to create, in a workspace the pusher belongs to.
398400 let owner = a
399401 .viewer
400402 .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()));
402404 let Some(owner) = owner else {
403405 return Ok(denied());
404406 };
405407 let created = self
406408 .create(CreateArgs {
407409 owner: owner.clone(),
410+ namespace: a.path.namespace.clone(),
408411 name: a.path.name.clone(),
409412 description: None,
410413 is_private: false,
436439 if !can_write(&target, &actor) {
437440 return Ok(Outcome::fail(
438441 FailureCode::Forbidden,
439− "Only the repository's owner can land an attempt.",
442+ "Only members of the repository's workspace can land an attempt.",
440443 ));
441444 }
442445 if !a.actor.verified {
627630 reply(
628631 &repos
629632 .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+ )
631639 .await?,
632640 )
633641 }
+35−6
4444 !repo.is_private || can_write(repo, viewer)
4545 }
4646
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.
4749 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+ })
4957 }
5058
5159 fn optional(value: &Option<String>) -> JsValue {
8189 }
8290
8391 /// Repos the viewer may see, newest first. Excludes attempt forks.
92+ /// With `member_only`, only repos in the viewer's own workspaces.
8493 pub async fn list(
8594 &self,
8695 viewer: &Viewer,
8796 query: Option<&str>,
8897 namespace: Option<&str>,
98+ member_only: bool,
8999 ) -> 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+ ];
93121 if let Some(namespace) = namespace {
94− conditions.push("namespace = ?");
122+ conditions.push("namespace = ?".to_owned());
95123 params.push(namespace.to_lowercase().into());
96124 }
97125 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());
99128 // LIKE wildcards in the query are matched literally.
100129 let escaped: String = query
101130 .chars()
+2−2
260260 Outcome::Ok(repo) => repo,
261261 Outcome::Fail(_) => return Ok(no_intent()),
262262 };
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) {
264264 return Ok(Outcome::fail(
265265 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.",
267267 ));
268268 }
269269 if intent.status != IntentStatus::Open {
+2−2
3636 author: User {
3737 id: row.author_id,
3838 username: row.author_name,
39− verified: false,
39+ ..User::default()
4040 },
4141 created_at: row.created_at,
4242 attempt_count: row.attempt_count,
8686 started_by: User {
8787 id: row.started_by_id,
8888 username: row.started_by_name,
89− verified: false,
89+ ..User::default()
9090 },
9191 created_at: row.created_at,
9292 updated_at: row.updated_at,