Commit

Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member

Packages (docs/PACKAGES.md, phase 1) - services/packages: a new service and D1 (g1t-packages). Files by SHA-256 through a BlobStore port: R2 in production, S3-compatible (MinIO in the self-host compose file) self-hosted. Uploads in fixed 10 MiB multipart parts with the SHA-256 state kept between requests; refused uploads clean up after themselves. - An OCI Distribution 1.1 registry on g1t.sh/v2: token exchange (docker login with a g1t token), pull, push (chunked, monolithic, mounts), manifests, indexes, artifacts and referrers, tags, deletes. Access is the linked repository's, else the workspace's; G1T_TOKEN and agents push their own repository's images. Scopes packages:read, write and delete. Anonymous and signed-in rate limits (optional bindings). - Events (package.*), audit, webhooks (workspace-level packages too), an hourly sweep of unused files, and workspace renames, deletes and soft deletes followed. - Free amounts from billing: a free workspace's pushes past 10 GB public or 500 MB private are refused; the plan pays past them at R2's price plus the margin (package_storage, measured daily, billing 0031). - Site: Packages for a workspace and each package (pull commands, versions, tags, platforms, attached signatures and attestations, settings); the sidebar link is real. Docs: packages and containers guides, self-hosting. - Self-host: a scheduler that runs services' crons, which workerd does not. Workspace deletion - An owner deletes a workspace in one step, typing its name: its repositories, projects, apps and packages go with it. It is kept 30 days (WORKSPACE_RESTORE_DAYS) and staff can restore it from sudo; then it is purged as deleting always did. workspace.deleting and workspace.restored events; each service hides and restores exactly what went with the workspace. - flagon-io can never be deleted or purged, by anyone: compiled in, flagged on its row (so a rename keeps it) and PROTECTED_WORKSPACES. Members - Members is in the main sidebar for every member, with Teams (soon) beside it; it is no longer a settings page.

syntaqxcommitted Parentf47f9daBrowse files
123 files+1584−1910/123 viewed
+16−0
985985 ]
986986
987987 [[package]]
988+name = "g1t-packages"
989+version = "0.1.0"
990+dependencies = [
991+ "base64 0.22.1",
992+ "futures-util",
993+ "g1t-contracts",
994+ "g1t-kit",
995+ "hex",
996+ "hmac 0.12.1",
997+ "serde",
998+ "serde_json",
999+ "sha2 0.10.9",
1000+ "worker",
1001+]
1002+
1003+[[package]]
9881004 name = "g1t-repos"
9891005 version = "0.1.0"
9901006 dependencies = [
+1−1
11 [workspace]
22 resolver = "3"
3−members = ["apps/api", "crates/*", "services/actions", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/search", "services/security", "services/work"]
3+members = ["apps/api", "crates/*", "services/actions", "services/billing", "services/events", "services/identity", "services/integrations", "services/packages", "services/webhooks", "services/repos", "services/search", "services/security", "services/work"]
44
55 [workspace.package]
66 edition = "2024"
+1−1
702702 }
703703 Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.",
704704 Op::DeleteWorkspace => {
705− "Delete a workspace. Owners only, signed in as a person, and confirm must be the workspace's slug. It must hold no repositories (move them with transfer_repo first) and no projects, and billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once. Its members, access tokens, webhooks, integrations and workspace secrets are removed; its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again."
705+ "Delete a workspace and everything in it. Owners only, signed in as a person, and confirm must be the workspace's slug. Billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once and its plan ends. Its repositories, projects and apps go with it at once, nobody can reach it, and its access tokens stop working. It is kept for 30 days, when g1t's support can restore it as it was; then it is purged, with its webhooks, integrations and workspace secrets. Its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again once it is purged. Some workspaces, such as Flagon's, can never be deleted."
706706 }
707707 Op::UpdateWorkspace => {
708708 "Change a workspace's display name and description, and what every member gets on each of its repositories (base_permission: none, read, write or admin). Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now."
+1−1
8888 "confirm": "acme-labs"
8989 },
9090 "response": true,
91− "notes": "Refused with `409` while the workspace holds repositories or projects, and with `402` while billing cannot settle it, each with a message that says what to do. See [Delete a workspace](/guides/workspaces/#delete-a-workspace)."
91+ "notes": "Refused with `402` while billing cannot settle it, with a message that says what to do, and with `403` for anyone but an owner signed in as a person, or for a protected workspace. Its repositories, projects and apps go with it; g1t's support can restore it for 30 days. See [Delete a workspace](/guides/workspaces/#delete-a-workspace)."
9292 },
9393 "list_emails": {
9494 "response": {
+1−1
231231 default_action: None,
232232 actions: &[
233233 a("create", Op::CreateWorkspace, "Create a workspace"),
234− a("delete", Op::DeleteWorkspace, "Delete an empty workspace"),
234+ a("delete", Op::DeleteWorkspace, "Delete a workspace and everything in it (support can restore it for 30 days)"),
235235 a("update", Op::UpdateWorkspace, "Change its name, description or base permission"),
236236 a("list_invites", Op::ListWorkspaceInvites, "Its invites"),
237237 a("invite_member", Op::InviteMember, "Invite an email address"),
+2−0
7373 items: [
7474 { label: 'Projects', slug: 'guides/projects' },
7575 { label: 'Deployments', slug: 'guides/deployments' },
76+ { label: 'Packages', slug: 'guides/packages' },
77+ { label: 'Container images', slug: 'guides/containers' },
7678 { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' },
7779 { label: 'Security', slug: 'guides/security' },
7880 ],
+2−2
105105
106106 Owners always have Admin, whatever it says. Only owners can change it:
107107
108−1. Open the workspace's **Settings → Members**, `g1t.sh/<workspace>/-/people`.
108+1. Open the workspace's **Members** in the sidebar, `g1t.sh/<workspace>/-/people`. Every member can see it; owners manage it.
109109 2. Under **Base permission**, choose one.
110110
111111 It takes effect on everyone's next request. To give one member more on
178178
179179 Owners see every outside collaborator, and the repositories and roles each
180180 has, on the **Outside collaborators** tab of the workspace's
181−**Settings → Members**. **Convert to member** adds one to the workspace
181+**Members**. **Convert to member** adds one to the workspace
182182 (see [members and roles](/guides/workspaces/#members-and-roles)); the
183183 roles they have stay, and the base permission adds to them.
184184
+4−1
1313
1414 - **A repository's lifecycle**, wherever the change was made, g1t.sh
1515 included. A transfer is recorded in both workspaces' logs, and a
16− workspace's deletion as `workspace.deleted`, its log's last entry.
16+ workspace's deletion as `workspace.deleted`. A restore by g1t's support
17+ is recorded as `workspace.restored`, and the purge 30 days after a
18+ deletion as `workspace.purged`, its log's last entry.
1719
1820 | Action | Recorded when |
1921 | --- | --- |
2729 | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had it changed, or lost it. See [access and roles](/guides/access-and-roles/). |
2830 | `repo.invitation_created`, `repo.invitation_revoked` | Someone was invited to it, or an invitation was withdrawn. |
2931 | `workspace.base_permission_changed` | An owner changed what members get on every repository. |
32+| `workspace.deleted`, `workspace.restored`, `workspace.purged` | An owner deleted the workspace, g1t's support restored it, or it was removed for good. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). |
3033
3134 Through the API and the MCP server, the call itself is recorded under its
3235 operation's name too, such as `delete_repo`. See
+6−2
317317 | Group | Scopes |
318318 | --- | --- |
319319 | Repositories & code | `repo:read`, `repo:write`, `code:read`, `code:write` |
320+| Packages | `packages:read`, `packages:write` |
320321 | Issues & pull requests | `issues:read`, `issues:write`, `pull_requests:read`, `pull_requests:write` |
321322 | Agents | `agents:run` |
322323 | Workflows | `workflows:read`, `workflows:write` |
324325 | Account | `account:read`, `account:write` |
325326 | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` |
326327 | Runners | `runners:read` |
327−| Dangerous | `repo:admin`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` |
328+| Dangerous | `repo:admin`, `packages:delete`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` |
328329
329330 Ticking a higher level ticks the lower ones of its resource and greys
330331 them out: tick `issues:write` and `issues:read` is ticked too. Untick
337338 | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts |
338339 | `code:read` | Clone and fetch private repositories with git |
339340 | `code:write` | Push commits with git |
341+| `packages:read` | Pull container images and install private [packages](/guides/packages/). Public ones need no scope. |
342+| `packages:write` | Push container images and publish packages |
343+| `packages:delete` | Delete packages and their versions |
340344 | `issues:read` | Read issues, comments and plans |
341345 | `issues:write` | Open, edit, close and comment on issues |
342346 | `pull_requests:read` | Read pull requests, their changes, sessions and merge queues |
395399 | --- | --- |
396400 | Read only | Every `read` scope. Changes nothing. |
397401 | Agent | Every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Reads everything, works on issues and pull requests, pushes code and puts g1t to work. No admin scope. |
398−| CI | `repo:read`, `code:read`, `code:write`, `workflows:read` and `workflows:write`. Clones and pushes code, and runs workflows. |
402+| CI | `repo:read`, `code:read`, `code:write`, `packages:read`, `packages:write`, `workflows:read` and `workflows:write`. Clones and pushes code, pushes and pulls packages, and runs workflows. |
399403 | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. |
400404
401405 Admin scopes change things that are hard to undo, or decide who can reach
+151−0
1+---
2+title: Container images
3+description: Push and pull container images on g1t.sh with docker, in workflows with G1T_TOKEN, and what to do about large layers.
4+---
5+
6+g1t.sh is a container registry. Images are named after their workspace,
7+pushed and pulled with `docker` or any client of the OCI Distribution
8+protocol, and have the access of the repository they are linked to (see
9+[who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package)).
10+
11+```text
12+g1t.sh/<workspace>/<name>[:<tag>]
13+```
14+
15+## Sign in
16+
17+Use your username, and an [access token](https://g1t.sh/settings/tokens) as
18+the password: one with full access, or with `packages:write` (to push) or
19+`packages:read` (to pull private images).
20+
21+```sh
22+echo "$G1T_TOKEN" | docker login g1t.sh -u <you> --password-stdin
23+```
24+
25+Public images pull without signing in.
26+
27+## Push
28+
29+Tag the image with its address and push it:
30+
31+```sh
32+docker build -t g1t.sh/acme/web:1.4.0 .
33+docker push g1t.sh/acme/web:1.4.0
34+```
35+
36+The first push makes the package. When its name starts with a repository
37+of the workspace, here `acme/web`, it is linked to that repository and
38+follows its visibility and roles; pushing needs Write on it. Otherwise it is
39+the workspace's, private, and needs the workspace's Write base permission.
40+
41+Pushing a tag again moves it to the new image. Layers already on g1t are
42+not uploaded again, and images in the same workspace share them.
43+
44+Multi-platform images (`docker buildx build --platform linux/amd64,linux/arm64 --push`),
45+OCI image indexes, and artifacts attached to an image with a `subject`
46+(signatures, SBOMs, attestations) are all kept; the registry lists an image's
47+attached artifacts at `/v2/<name>/referrers/<digest>`.
48+
49+## Pull
50+
51+```sh
52+docker pull g1t.sh/acme/web:1.4.0
53+docker pull g1t.sh/acme/web@sha256:…
54+```
55+
56+## In workflows
57+
58+A workflow's `G1T_TOKEN` is the workspace's own token for the run, and can
59+push and pull the workspace's images:
60+
61+```yaml
62+jobs:
63+ image:
64+ runs-on: ubuntu-latest
65+ steps:
66+ - uses: actions/checkout@v4
67+ - name: Sign in to g1t.sh
68+ run: echo "${{ secrets.G1T_TOKEN }}" | docker login g1t.sh -u g1t --password-stdin
69+ - name: Build and push
70+ run: |
71+ docker build -t g1t.sh/${{ github.repository }}:${{ github.sha }} .
72+ docker push g1t.sh/${{ github.repository }}:${{ github.sha }}
73+```
74+
75+Runs that get no secrets (a pull request from someone without Write) get an
76+empty token, and cannot push. See
77+[secrets and variables](/guides/actions/#secrets-and-variables).
78+
79+## The 100 MB limit
80+
81+A single request to g1t.sh may carry at most 100 MB. `docker push` sends
82+each layer whole, in one request, and so do the other common clients
83+(`crane push` and `oras push` included), so a layer over 100 MB, as
84+compressed for the push, is refused.
85+
86+What you see depends on where it is refused. Usually it is before the
87+request reaches g1t, and `docker push` stops with a bare status:
88+
89+```text
90+unknown: failed commit on ref "layer-sha256:…": unexpected status from PUT request to https://g1t.sh/v2/acme/web/blobs/uploads/…?digest=sha256%3A…: 413 Request Entity Too Large
91+```
92+
93+(the request may be a `PATCH` instead of a `PUT`, and some versions of
94+Docker show the HTML page that came with the 413 instead). When g1t sees
95+the request itself, the error is `SIZE_INVALID`, with a message naming the
96+limit and this page.
97+
98+A client that uploads a layer in chunks, each its own request under
99+100 MB, is not limited: the layer can then be any size.
100+
101+To stay under the limit, keep each layer under 100 MB:
102+
103+- build in stages, and copy only what the image needs into the last one;
104+- split a large `RUN` or `COPY` into several, so each makes its own layer;
105+- leave caches, build tools and test data out of the image (`.dockerignore`).
106+
107+An installation you [run yourself](/guides/self-hosting/) has no such
108+limit.
109+
110+## Storage and pull limits
111+
112+Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), a
113+workspace's public packages may hold 10 GB and its private ones 500 MB,
114+each file counted once. A push that would go past either is refused with
115+`DENIED` and a message saying how much is used; layers the workspace
116+already holds add nothing. On the plan nothing is refused: storage past
117+the free amounts is charged.
118+
119+Anonymous pulls are limited to 300 requests a minute from each address, and
120+signed-in ones to 5,000 a minute for each person, workspace or agent.
121+Past the limit, requests are answered `429` with `TOOMANYREQUESTS` and a
122+`Retry-After`; docker waits and tries again. Signing in raises the limit.
123+
124+## Delete
125+
126+Deleting needs Admin on the linked repository, or for an unlinked image, an
127+owner of the workspace; a token needs `packages:delete`.
128+
129+The registry protocol's `DELETE` removes a tag, or a whole version by its
130+digest (with every tag that points to it):
131+
132+```sh
133+TOKEN=$(curl -s -u <you>:<token> "https://g1t.sh/v2/token?scope=repository:acme/web:delete" | jq -r .token)
134+curl -X DELETE -H "Authorization: Bearer $TOKEN" https://g1t.sh/v2/acme/web/manifests/1.4.0
135+curl -X DELETE -H "Authorization: Bearer $TOKEN" https://g1t.sh/v2/acme/web/manifests/sha256:…
136+```
137+
138+Layers no version uses any more are deleted from storage a day later.
139+
140+## Errors
141+
142+| Error | Means |
143+| --- | --- |
144+| `UNAUTHORIZED` | Not signed in, or the token is wrong or expired. `docker login g1t.sh` again. |
145+| `DENIED` | Signed in, but your role or your token's scopes do not allow it. The message says which. |
146+| `NAME_UNKNOWN` | No such image, or one you cannot see. |
147+| `MANIFEST_UNKNOWN`, `BLOB_UNKNOWN` | No such tag, digest or layer in that image. |
148+| `NAME_INVALID` | Names are lowercase letters and digits, separated by `.`, `_`, `__`, `-` or `/`, and start with a workspace. |
149+| `SIZE_INVALID`, or a bare `413` | A request over [the 100 MB limit](#the-100-mb-limit). |
150+| `TOOMANYREQUESTS` | Too many requests in a minute; see [the limits](#storage-and-pull-limits). |
151+| `DIGEST_INVALID` | What was uploaded does not have the digest the client said. Push again. |
+92−0
1+---
2+title: Packages
3+description: Publish and install packages beside your code, with the same people, roles and tokens.
4+---
5+
6+A workspace can publish packages to g1t and install them from it, beside
7+the code they are built from. Container images come first; npm, Composer,
8+Cargo and Go follow. Each registry speaks its tool's own protocol, so
9+`docker` works with nothing but a login and an address.
10+
11+| Registry | Address | Guide |
12+| --- | --- | --- |
13+| Container images | `g1t.sh/<workspace>/<name>` | [Container images](/guides/containers/) |
14+
15+## Names
16+
17+Every package's name starts with its workspace: `g1t.sh/acme/web` is the
18+`web` image of the `acme` workspace. A name may have more parts after it,
19+such as `g1t.sh/acme/web/worker`.
20+
21+## Who can see and publish a package
22+
23+A package is linked to a repository, or belongs to its workspace.
24+
25+- **Linked.** The first push of a package whose name starts with a
26+ repository's name (`acme/web`, `acme/web/worker` for the repository
27+ `acme/web`) links it to that repository. It then has the repository's
28+ visibility and [roles](/guides/access-and-roles/):
29+
30+ | | Needs |
31+ | --- | --- |
32+ | Pull | Read: on a public repository, anyone, signed in or not |
33+ | Push a new version or tag | Write |
34+ | Delete versions and the package, change its settings | Admin |
35+
36+- **Unlinked.** A package whose name matches no repository is the
37+ workspace's. It is private: members pull and push it by the workspace's
38+ [base permission](/guides/access-and-roles/#the-base-permission) (Read pulls,
39+ Write pushes), and only owners delete it or change its settings. An owner
40+ can make it public, and then anyone can pull it.
41+
42+A linked package can be unlinked, and an unlinked one linked to a
43+repository of its workspace by someone with Admin on that repository.
44+
45+Private packages look exactly like ones that do not exist to anyone who may
46+not pull them.
47+
48+## Tokens
49+
50+Sign in to a registry with your username and an
51+[access token](/guides/authentication/#access-tokens) as the password.
52+A token with scopes needs the package ones:
53+
54+| Scope | Lets a token |
55+| --- | --- |
56+| `packages:read` | Pull private packages. Public ones need no scope. |
57+| `packages:write` | Push and publish. Includes `packages:read`. |
58+| `packages:delete` | Delete versions and packages |
59+
60+Tokens with full access, and tokens made before scopes, have all three. A
61+token never does more than its owner could: `packages:delete` alone does not
62+let a member delete an owner's package.
63+
64+In [workflows](/guides/actions/#secrets-and-variables), `G1T_TOKEN` is the
65+workspace's own token for the run: it pushes and pulls the workspace's
66+packages with no setup. A g1t agent at work on a repository may push the
67+packages of that repository, as it may push its code, and never deletes
68+them.
69+
70+## Storage
71+
72+Every file is kept once, by its content: two images that share a layer
73+store it once, and a layer pushed again is not stored again. A workspace's
74+storage counts each file once, as public when any public package uses it.
75+
76+Files no version uses any more are deleted a day after the last version
77+that used them goes.
78+
79+Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), public
80+packages may hold 10 GB and private ones 500 MB per workspace; a push past
81+either is refused, with a message saying how much is used. On the plan,
82+storage past those amounts is charged instead. See
83+[storage and pull limits](/guides/containers/#storage-and-pull-limits).
84+
85+## Events and the audit log
86+
87+Publishing a version, deleting a version and deleting a package are
88+[audit log](/guides/audit-log/) entries, and the events
89+`package.published`, `package.version_deleted`, `package.deleted` and
90+`package.visibility_changed`, which [webhooks](/guides/webhooks/) can be
91+sent: a linked package's go to its repository's webhooks and its
92+workspace's, an unlinked package's to its workspace's webhooks.
+6−2
1919 | Workspaces, members, access tokens | Works |
2020 | Repositories: create, push and clone over HTTP, browse code, commits | Works |
2121 | Issues, comments, labels | Works |
22+| [Container images](/guides/containers/): `docker login`, push and pull at your `PUBLIC_URL` | Works, kept in the bundled MinIO, with no limit on a layer's size or on pulls |
2223 | Site search | Works |
2324 | A status page of your own | Works, at `http://localhost:8788` ([below](#the-status-page)) |
24−| Webhooks, integrations | Run, but scheduled retries do not (see below) |
25+| Webhooks, integrations | Work, retries included |
2526 | Sign in with GitHub, import from GitHub | Off until you register a GitHub App of your own ([below](#sign-in-with-github-and-import-from-github)). Mirrors sync with **Sync now**: GitHub's webhook needs the REST API. |
2627 | g1t's agent: changes, plans and reviews | Off |
2728 | Context hub search | Off |
2829 | Deployments on `g1t.page` | Off |
2930 | Billing | Off. Nothing is charged, and no usage limit stops work. |
3031 | Git over SSH, the REST API, MCP and the `g1t` CLI | Not available yet |
31−| Scheduled jobs (webhook retries, Actions schedules) | Not run yet |
32+| Scheduled jobs | Run once a minute inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention and access request summaries. Actions schedules (`on: schedule`) are not run. |
3233
3334 What hosted g1t cannot do yet either is on
3435 [What g1t can't do yet](/about/limitations/).
105106 | `REGISTRATION_MODE` | `open` | `open`: anyone can make an account. `invite`: every new account needs an [invite](/guides/authentication/#invites), as on g1t.sh. |
106107 | `INVITES_PER_USER` | `5` | How many invites each person can have out, while `REGISTRATION_MODE` is `invite` |
107108 | `WAITLIST_NOTIFY_EMAIL` | (none) | Where a summary of new access requests goes, at most every 15 minutes. Empty sends none; requests still wait for you in the database. |
109+| `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | the bundled MinIO, bucket `g1t-packages` | Where packages' files are kept: any S3-compatible store. Change the two keys before first start; MinIO is made with them. |
110+| `S3_PUBLIC_ENDPOINT` | (none) | The store's address as clients reach it. When set, large layers are downloaded from it directly with a signed URL. |
108111 | `STATUS_PORT` | `8788` | The port the status page is published on |
109112 | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. |
110113 | `INVITE_STAFF_WORKSPACES` | (none) | Workspace slugs, comma separated, whose owners can make invites without a limit. Set it to your own workspace before you switch to `invite`, so someone can invite the first people. |
183186 | --- | --- |
184187 | `g1t_g1t-data` | Accounts, workspaces, issues and every other record, as SQLite files; the keys that seal stored secrets (`keys.env`) |
185188 | `g1t_g1t-git` | Your repositories, one bare git repository each |
189+| `g1t_g1t-packages` | Container images' layers and other package files (MinIO) |
186190 | `g1t_g1t-secrets` | The key the site and the git store share |
187191
188192 To back up, stop g1t and copy the volumes:
+1−1
1313 | | Sent the events of | Managed by | Where |
1414 | --- | --- | --- | --- |
1515 | A repository's | That repository | People with the Admin [role](/guides/access-and-roles/) on it | The project's **Settings → Webhooks** |
16−| A workspace's | Every repository in the workspace | Owners | The workspace's **Settings → Webhooks** |
16+| A workspace's | Every repository in the workspace, and the workspace's own [packages](/guides/packages/) | Owners | The workspace's **Settings → Webhooks** |
1717
1818 Seeing a repository's webhooks, and their deliveries, needs Admin too: the
1919 page is not shown to anyone else, since a webhook's address and secret are
+46−25
143143
144144 ## Delete a workspace
145145
146−Deleting a workspace removes it for good. Only an owner can, signed in as a
147−person, and only once nothing is left in it.
146+Deleting a workspace takes everything in it with it, in one step: its
147+repositories, projects, apps, members' access and tokens. Only an owner can,
148+signed in as a person, typing the workspace's slug to confirm.
148149
149−Before you start:
150−
151−1. **Move or delete its repositories.** [Transfer](/guides/transferring-repositories/)
152− each one you want to keep to another workspace you own; their old
153− addresses keep redirecting after the workspace is gone.
154− [Delete](/guides/managing-repositories/#delete-a-repository) the ones
155− you do not. Recently deleted repositories do not stand in the way: they
156− are purged with the workspace, and cannot be restored afterwards.
157−2. **Move its projects out.** A project that builds from a repository in
158− another workspace does not move with a transfer; it stops the deletion
159− until it is gone.
160−3. **Settle billing.** See [what billing needs](#what-billing-needs).
161−
162−Then:
150+It is not gone at once. For **30 days** g1t keeps all of it, so that a
151+deletion you did not mean, or did not make, can be undone: an owner writes
152+to support@g1t.sh, and support restores the workspace as it was. After 30
153+days it is purged for good.
163154
164155 1. Open the workspace's **Settings → General** and go to **Danger zone**.
165− It says what is still in the way, if anything.
156+ It lists what will go with the workspace: its repositories, projects,
157+ live apps and members.
166158 2. Choose **Delete workspace**, read what happens, type the workspace's
167− slug to confirm, and choose **Delete workspace** again.
159+ slug to confirm, and choose **Delete workspace** again. You are taken
160+ back to your own home.
168161
162+The one thing that can stand in the way is billing: see
163+[what billing needs](#what-billing-needs). Repositories you want to keep in
164+another workspace, [transfer](/guides/transferring-repositories/) first;
165+their old addresses keep redirecting after the workspace is gone.
166+
169167 From the API, call
170168 [`DELETE /workspaces/{workspace}`](/reference/api/workspaces/delete-workspace/)
171169 with the slug in `confirm`; over MCP, the `workspace` tool's `delete`
172170 action.
173171
172+Some workspaces can never be deleted, by anyone, such as Flagon's, which
173+runs g1t. Their Danger zone says so instead of offering the button.
174+
174175 ### What billing needs
175176
176177 | | |
186187
187188 ### What happens
188189
190+At once, when an owner deletes it:
191+
189192 | | |
190193 | --- | --- |
191−| Members | Lose access. Their own accounts are not touched: a person with no workspace left can still sign in, and create or join one. |
192−| Access tokens | The workspace's own tokens stop working at once. Personal tokens are not affected. |
194+| Members | Lose access, and the workspace leaves their list. Their own accounts are not touched: a person with no workspace left can still sign in, and create or join one. |
195+| Access tokens | The workspace's own tokens stop working. Personal tokens are not affected. |
196+| Repositories | Deleted with it: git refuses them, and their pages answer 404. Agents and workflow runs stop. Ones deleted on their own earlier stay deleted. |
197+| Projects and apps | Hidden. Its apps are taken offline and nothing builds. Custom domains are kept for a restore. |
198+| Its pages | Answer 404, and it drops out of search. |
199+| Billing | What it owes is charged, and its plan ends, as [billing needs](#what-billing-needs). Nothing more is charged. |
200+| The audit log | Records the deletion. |
201+
202+Within 30 days, support can restore it: its members, tokens, repositories,
203+projects and apps come back as they were, and its apps go back up as its
204+limit allows. Its plan does not come back by itself: an owner starts it
205+again from **Billing**. A repository deleted on its own before the
206+workspace was stays in **Recently deleted**.
207+
208+After 30 days it is purged:
209+
210+| | |
211+| --- | --- |
212+| Repositories | Purged, their git data with them, including any that were in Recently deleted. |
213+| Projects, apps and custom domains | Removed. |
193214 | Webhooks, integrations, secrets and variables | The workspace's own are removed. |
194215 | Memory and guardrails | The workspace's own are removed. |
195216 | Statements, invoices and the ledger | Kept, for accounting. |
196−| The audit log | Kept as [long as its account keeps it](/guides/audit-log/#how-long-it-is-kept), with the deletion as its last entry: once the plan ends with the workspace, that is 7 days, unless an enterprise pays for it or longer was arranged. With no owners left, ask support@g1t.sh for an export. |
197−| Recently deleted repositories | Purged with it, their git data with them. |
217+| The audit log | Kept as [long as its account keeps it](/guides/audit-log/#how-long-it-is-kept), with the purge as its last entry: once the plan ends with the workspace, that is 7 days, unless an enterprise pays for it or longer was arranged. With no owners left, ask support@g1t.sh for an export. |
198218 | Old addresses | Redirects for repositories transferred out keep working. The workspace's own pages answer 404. |
199219
200220 ### The name afterwards
201221
202222 A deleted workspace's slug is never given to another workspace or used as
203−someone else's username. Links and git remotes that still use it keep
223+someone else's username. While it can still be restored, the slug is held
224+for it. Links and git remotes that still use it keep
204225 meaning what they meant: a transferred repository's old address keeps
205226 redirecting to it, and nobody can take the name in the meantime.
206227
207228 The one exception: when the slug is your own username, you may create a
208−workspace with that name again. It starts empty, on standard billing terms,
229+workspace with that name again once the old one is purged. It starts empty, on standard billing terms,
209230 and a repository made in it at an old address ends that address's redirect.
210231
211232 ## Members and roles
216237 | Owner | Everything a member can, and manage members, the base permission, the workspace's access tokens, its details, and billing: the plan, card checks, prepayment and limits. Admin on every repository, and the only ones who can transfer and delete them; see [access and roles](/guides/access-and-roles/). |
217238
218239 Whoever creates a workspace is its owner. An owner adds people on the
219−workspace's **Settings → Members**, `g1t.sh/<workspace>/-/people`:
240+workspace's **Members** (in the sidebar), `g1t.sh/<workspace>/-/people`:
220241
221242 - **By username**: someone already on g1t joins at once, as a member.
222243 - **By email address**: g1t emails an invite that only that address can
+0−0

Binary or large file; its contents are not shown.

+19−1
5151 with Stripe's page and PDF), its terms, who it is billed to (move it onto or off an
5252 enterprise), a credit form, a Stripe billing link, its ledger and its
5353 audit log. If billing does not answer for sales or invoices, the page
54− still opens and says so in those sections.
54+ still opens and says so in those sections. A protected workspace (one
55+ nobody can ever delete: identity's `PROTECTED_WORKSPACES`, and
56+ flagon-io always) says so beside its name.
57+- **Deleted workspaces** (`/workspaces/deleted`, linked from Workspaces):
58+ workspaces their owners deleted, newest first (`admin_deleted_workspaces`),
59+ each with who deleted it and when, when it is purged, what went with it
60+ (repositories, projects, members, counted at the deletion) and the days
61+ left. An owner deletes a workspace with everything in it in one step, and
62+ identity keeps it 30 days (`WORKSPACE_RESTORE_DAYS`) so support can undo a
63+ deletion that was a mistake or not theirs to make. **Restore**
64+ (`admin_restore_workspace`) brings it back with its members and tokens,
65+ and its repositories, projects and apps with `workspace.restored`; its
66+ plan stays ended, so its owners start it again from Billing. Check that
67+ whoever asks is an owner of it before restoring. **Purge now**
68+ (`admin_purge_workspace`, the slug typed to confirm) removes it at once,
69+ as the sweep does every 15 minutes once its 30 days are up; never for a
70+ protected workspace. Both go in the workspace's audit log, as g1t, and in
71+ sudo's (`workspace_restored`, `workspace_purged`), naming the staff
72+ member.
5573 - **Enterprises**: customers that pay for several workspaces with one
5674 bill, one limit and one set of terms. Each has its workspaces (add or
5775 remove them), combined usage, terms, credits, ledger and audit log, and
+30−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { confirmsPurge, daysLeft, wentSummary } from "./deleted-workspaces.ts";
5+
6+test("what went reads as one line", () => {
7+ assert.equal(
8+ wentSummary({ repositories: 3, projects: 1, members: 2, billing: null, protected: false }),
9+ "3 repositories, 1 project, 2 members",
10+ );
11+ assert.equal(
12+ wentSummary({ repositories: 1, projects: 0, members: 1, billing: null, protected: false }),
13+ "1 repository, 0 projects, 1 member",
14+ );
15+});
16+
17+test("days left round up, and none once it is due", () => {
18+ const now = Date.parse("2026-10-06T12:00:00Z");
19+ assert.equal(daysLeft("2026-11-05T12:00:00Z", now), 30);
20+ assert.equal(daysLeft("2026-10-06T13:00:00Z", now), 1);
21+ assert.equal(daysLeft("2026-10-06T12:00:00Z", now), 0);
22+ assert.equal(daysLeft("2026-10-01T00:00:00Z", now), 0);
23+ assert.equal(daysLeft("not a date", now), 0);
24+});
25+
26+test("a purge is confirmed by the slug alone", () => {
27+ assert.ok(confirmsPurge("acme", " Acme "));
28+ assert.ok(!confirmsPurge("acme", ""));
29+ assert.ok(!confirmsPurge("acme", "acme-inc"));
30+});
+30−0
1+/**
2+ * Deleted workspaces, as the Deleted workspaces page shows them: what went
3+ * with each, how long is left to restore it, and what staff must type to
4+ * purge one now. No Workers imports, so it can be tested under Node.
5+ */
6+import type { WorkspaceDeletion } from "@g1t/contracts";
7+
8+const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
9+
10+/** What went with a workspace, in a line: "3 repositories, 1 project, 2 members". */
11+export function wentSummary(went: WorkspaceDeletion): string {
12+ const parts = [
13+ plural(went.repositories, "repository", "repositories"),
14+ plural(went.projects, "project", "projects"),
15+ plural(went.members, "member", "members"),
16+ ];
17+ return parts.join(", ");
18+}
19+
20+/** Whole days left before `purgeAfter` (RFC 3339), from `now`; 0 once it is due. */
21+export function daysLeft(purgeAfter: string, now: number): number {
22+ const left = Date.parse(purgeAfter) - now;
23+ if (!Number.isFinite(left) || left <= 0) return 0;
24+ return Math.ceil(left / 86_400_000);
25+}
26+
27+/** Whether what staff typed to purge a workspace now is its slug. Identity checks it again. */
28+export function confirmsPurge(slug: string, typed: string): boolean {
29+ return typed.trim().toLowerCase() === slug.toLowerCase();
30+}
+4−0
105105 note: "Sales note added",
106106 stripe: "From Stripe",
107107 webhook: "Stripe webhook registered",
108+ close: "Billing closed",
109+ // From identity: deleted workspaces staff restored or purged.
110+ workspace_restored: "Workspace restored",
111+ workspace_purged: "Workspace purged",
108112 // From the status page (apps/status), merged in by the Audit log page.
109113 incident_declared: "Incident declared",
110114 incident_detected: "Incident detected",
+1−0
55 export default [
66 index("routes/overview.tsx"),
77 route("workspaces", "routes/workspaces.tsx"),
8+ route("workspaces/deleted", "routes/deleted-workspaces.tsx"),
89 route("workspaces/:slug", "routes/workspace.tsx"),
910 route("users/:username", "routes/user.tsx"),
1011 route("enterprises", "routes/enterprises.tsx"),
+156−0
1+import { ArrowLeft } from "lucide-react";
2+import { Link, data, redirect } from "react-router";
3+
4+import { WORKSPACE_RESTORE_DAYS, type DeletedWorkspace } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/deleted-workspaces";
7+import { Badge, Button, EmptyState, Input, Notice, PageHeader, When } from "~/components/ui";
8+import { confirmsPurge, daysLeft, wentSummary } from "~/lib/deleted-workspaces";
9+import { text } from "~/lib/forms";
10+import { identity } from "~/lib/services.server";
11+import { settle } from "~/lib/settle";
12+import { requireStaff } from "~/lib/staff";
13+
14+export const meta: Route.MetaFunction = () => [
15+ { title: "Deleted workspaces · sudo" },
16+ { name: "robots", content: "noindex, nofollow" },
17+];
18+
19+export async function loader({ request, context }: Route.LoaderArgs) {
20+ requireStaff(context);
21+ const url = new URL(request.url);
22+ const deleted = await settle(identity.deletedWorkspaces());
23+ const done = url.searchParams.get("done");
24+ const slug = url.searchParams.get("slug") ?? "";
25+ return {
26+ workspaces: deleted.ok ? deleted.value : [],
27+ error: deleted.ok ? null : deleted.error,
28+ done: done === "restored" ? `Restored ${slug}.` : done === "purged" ? `Purged ${slug}.` : null,
29+ now: Date.now(),
30+ };
31+}
32+
33+/**
34+ * Restoring and purging. Identity checks the window, the typed slug and
35+ * protection again, and records each in the workspace's audit log and in
36+ * sudo's, naming the staff member.
37+ */
38+export async function action({ request, context }: Route.ActionArgs) {
39+ const staff = requireStaff(context);
40+ const form = await request.formData();
41+ const id = text(form, "id");
42+ const slug = text(form, "slug");
43+ const back = (done: string) => redirect(`/workspaces/deleted?done=${done}&slug=${encodeURIComponent(slug)}`);
44+ switch (text(form, "intent")) {
45+ case "restore": {
46+ const result = await identity.restoreWorkspace(id, staff.email);
47+ if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
48+ throw back("restored");
49+ }
50+ case "purge": {
51+ const confirm = text(form, "confirm");
52+ if (!confirmsPurge(slug, confirm)) return data({ error: `Type ${slug} to confirm.`, id }, { status: 422 });
53+ const result = await identity.purgeWorkspace(id, staff.email, confirm);
54+ if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
55+ throw back("purged");
56+ }
57+ }
58+ return data({ error: "Unknown action.", id }, { status: 400 });
59+}
60+
61+export default function DeletedWorkspaces({ loaderData, actionData }: Route.ComponentProps) {
62+ const { workspaces, error, done, now } = loaderData;
63+ const errorFor = (id: string) => (actionData && "id" in actionData && actionData.id === id ? actionData.error : null);
64+ return (
65+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
66+ <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
67+ <ArrowLeft size={14} />
68+ Workspaces
69+ </Link>
70+ <div className="mt-4">
71+ <PageHeader
72+ title="Deleted workspaces"
73+ description={`Workspaces their owners deleted, with everything in them. Each is kept for ${WORKSPACE_RESTORE_DAYS} days: restore one an owner asks back, as after a deletion they did not mean or did not make. Then it is purged for good.`}
74+ />
75+ </div>
76+ <div className="mt-6 space-y-3">
77+ {done && <Notice tone="ok">{done}</Notice>}
78+ {error && <Notice tone="error">Identity did not answer: {error}</Notice>}
79+ </div>
80+ {workspaces.length === 0 ? (
81+ <div className="mt-6">
82+ <EmptyState title="No deleted workspaces">Workspaces appear here when an owner deletes one, until they are purged.</EmptyState>
83+ </div>
84+ ) : (
85+ <ul className="mt-6 space-y-3">
86+ {workspaces.map((workspace) => (
87+ <DeletedRow key={workspace.workspaceId} workspace={workspace} now={now} error={errorFor(workspace.workspaceId)} />
88+ ))}
89+ </ul>
90+ )}
91+ </main>
92+ );
93+}
94+
95+function DeletedRow({ workspace, now, error }: { workspace: DeletedWorkspace; now: number; error: string | null }) {
96+ const left = daysLeft(workspace.purgeAfter, now);
97+ return (
98+ <li className="rounded-lg border border-line bg-surface p-4 sm:p-5">
99+ <div className="flex flex-wrap items-start justify-between gap-3">
100+ <div className="min-w-0">
101+ <p className="font-medium">
102+ {workspace.name} <span className="font-mono text-sm text-muted">{workspace.slug}</span>
103+ </p>
104+ <p className="mt-1 text-sm text-muted">
105+ Deleted by <span className="text-fg-soft">{workspace.deletedBy || "an owner"}</span> <When at={workspace.deletedAt} time />
106+ {" · "}purged <When at={workspace.purgeAfter} time />
107+ </p>
108+ <p className="mt-1 text-sm text-muted">Went with it: {wentSummary(workspace.went)}</p>
109+ </div>
110+ <div className="flex flex-wrap gap-1.5">
111+ {workspace.went.protected && <Badge tone="info">Protected</Badge>}
112+ {workspace.restorable ? (
113+ <Badge tone="warn">
114+ {left} day{left === 1 ? "" : "s"} left
115+ </Badge>
116+ ) : (
117+ <Badge tone="danger">Being purged</Badge>
118+ )}
119+ </div>
120+ </div>
121+ {error && (
122+ <div className="mt-3">
123+ <Notice tone="error">{error}</Notice>
124+ </div>
125+ )}
126+ <div className="mt-4 flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between">
127+ <form method="post">
128+ <input type="hidden" name="intent" value="restore" />
129+ <input type="hidden" name="id" value={workspace.workspaceId} />
130+ <input type="hidden" name="slug" value={workspace.slug} />
131+ <Button type="submit" variant="lavender" disabled={!workspace.restorable}>
132+ Restore
133+ </Button>
134+ </form>
135+ {workspace.went.protected ? (
136+ <p className="text-sm text-muted">Protected: it can never be purged.</p>
137+ ) : (
138+ <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
139+ <input type="hidden" name="intent" value="purge" />
140+ <input type="hidden" name="id" value={workspace.workspaceId} />
141+ <input type="hidden" name="slug" value={workspace.slug} />
142+ <label className="grid gap-1 text-xs text-muted">
143+ <span>
144+ Type <span className="font-mono text-fg">{workspace.slug}</span> to purge it now
145+ </span>
146+ <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" aria-label={`Type ${workspace.slug} to purge it now`} />
147+ </label>
148+ <Button type="submit" variant="danger">
149+ Purge now
150+ </Button>
151+ </form>
152+ )}
153+ </div>
154+ </li>
155+ );
156+}
+1−0
208208 </p>
209209 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
210210 <div className="mt-2 flex flex-wrap gap-1.5">
211+ {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
211212 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
212213 <TermsBadge terms={terms} />
213214 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
+11−3
9494
9595 return (
9696 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
97− <div>
98− <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1>
99− <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p>
97+ <div className="flex flex-wrap items-start justify-between gap-4">
98+ <div>
99+ <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1>
100+ <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p>
101+ </div>
102+ <Link
103+ to="/workspaces/deleted"
104+ className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg"
105+ >
106+ Deleted workspaces
107+ </Link>
100108 </div>
101109
102110 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
+26−60
1−import {
2− Activity,
3− ServerCog,
4− GanttChart,
5− KanbanSquare,
6− Package,
7− Sparkles,
8− BarChart3,
9− BookMarked,
10− BookOpen,
11− Check,
12− ChevronLeft,
13− ChevronRight,
14− ChevronsUpDown,
15− CircleUserRound,
16− Mail,
17− Ticket,
18− CircleDot,
19− Code2,
20− Compass,
21− CreditCard,
22− GitPullRequest,
23− History,
24− House,
25− Fingerprint,
26− Gauge,
27− KeyRound,
28− LifeBuoy,
29− Box,
30− LayoutGrid,
31− ListTree,
32− Lock,
33− LogIn,
34− LogOut,
35− Menu,
36− Plus,
37− Search,
38− Plug,
39− Settings,
40− Users,
41− Webhook,
42− Globe,
43− GitBranch,
44− PlayCircle,
45− Bot,
46− Brain,
47− Network,
48− ShieldCheck,
49− Rocket,
50− X,
51−} from "lucide-react";
1+import { Activity, BarChart3, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
522 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
533 import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
544
232182 const WORKSPACE_ICONS: Record<string, ReactNode> = {
233183 board: <KanbanSquare size={15} />,
234184 roadmap: <GanttChart size={15} />,
185+ teams: <UsersRound size={15} />,
235186 packages: <Package size={15} />,
236187 fleet: <Bot size={15} />,
237188 };
488439 }
489440
490441 /**
491− * A workspace's settings pages, which the sidebar drills into: who belongs,
492− * what it pays, and its record. What it builds and runs with (secrets,
493− * integrations, webhooks, guardrails) sits in the main list.
442+ * A workspace's settings pages, which the sidebar drills into: what it
443+ * pays, its repositories, tokens and record. Who belongs (Members, Teams)
444+ * and what it builds and runs with (secrets, integrations, webhooks,
445+ * guardrails) sit in the main list, for every member to see.
494446 */
495−const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|repositories|tokens|billing|audit)(\/|$)/;
447+const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|repositories|tokens|billing|audit)(\/|$)/;
496448 /** A project's settings pages, which the project's menu drills into. */
497449 const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
498450
683635 <>
684636 <Rule />
685637 <div className="space-y-px">
638+ {/* Who belongs, for every member to see; owners invite and manage there. */}
639+ <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
640+ Members
641+ </SidebarLink>
642+ {roadmapIn("Workspace")
643+ .filter((item) => item.key === "teams")
644+ .map((item) => (
645+ <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}>
646+ {item.title}
647+ </SidebarSoonLink>
648+ ))}
649+ </div>
650+ <Rule />
651+ <div className="space-y-px">
686652 <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
687653 Agent fleet
688654 </SidebarLink>
704670 <SidebarLink to={`/${ws.slug}/-/runners`} icon={<ServerCog size={15} />}>
705671 Runners
706672 </SidebarLink>
673+ <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}>
674+ Packages
675+ </SidebarLink>
707676 <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}>
708677 Integrations
709678 </SidebarLink>
710679 <SidebarLink to={`/${ws.slug}/-/webhooks`} icon={<Webhook size={15} />}>
711680 Webhooks
712681 </SidebarLink>
713− {roadmapIn("Workspace").map((item) => (
682+ {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => (
714683 <SidebarSoonLink
715684 key={item.key}
716685 to={`/${ws.slug}/-/soon/${item.key}`}
732701 </SidebarLink>
733702 {/* Who belongs, what it pays and its record: a list of their own. */}
734703 <SidebarLink
735− to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
704+ to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`}
736705 icon={<Settings size={15} />}
737706 drill
738707 >
756725 General
757726 </SidebarLink>
758727 )}
759− <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
760− Members
761− </SidebarLink>
762728 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
763729 Repositories
764730 </SidebarLink>
+25−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { formatBytes, installCommands, shortDigest } from "./packages.ts";
5+
6+test("sizes read as registries show them", () => {
7+ assert.equal(formatBytes(0), "0 B");
8+ assert.equal(formatBytes(812), "812 B");
9+ assert.equal(formatBytes(12_340_000), "12.3 MB");
10+ assert.equal(formatBytes(1_400_000_000), "1.4 GB");
11+ assert.equal(formatBytes(250_000_000), "250 MB");
12+});
13+
14+test("digests shorten for lists", () => {
15+ assert.equal(shortDigest("sha256:3f2a9c1b7d0e55aa"), "3f2a9c1b7d0e");
16+});
17+
18+test("a container image is pulled by its address and tag", () => {
19+ const pkg = { ecosystem: "container" as const, address: "g1t.sh/acme/web", name: "web", workspace: "acme" };
20+ assert.deepEqual(installCommands(pkg, "latest", "ada"), {
21+ login: "docker login g1t.sh -u ada",
22+ install: "docker pull g1t.sh/acme/web:latest",
23+ });
24+ assert.equal(installCommands(pkg, null, "ada").install, "docker pull g1t.sh/acme/web");
25+});
+73−0
1+/**
2+ * Words and commands for the Packages pages: sizes, digests, and how each
3+ * registry's own tool logs in and installs.
4+ */
5+import type { Ecosystem, PackageSummary } from "@g1t/contracts";
6+
7+/** What each registry is called on the pages. */
8+export const ECOSYSTEM_LABEL: Record<Ecosystem, string> = {
9+ container: "Container",
10+ npm: "npm",
11+ composer: "Composer",
12+ cargo: "Cargo",
13+ go: "Go",
14+};
15+
16+/** "0 B", "812 KB", "12.3 MB", "1.4 GB", in powers of 1,000 as registries show them. */
17+export function formatBytes(bytes: number): string {
18+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
19+ const units = ["B", "KB", "MB", "GB", "TB"];
20+ let value = bytes;
21+ let unit = 0;
22+ while (value >= 1000 && unit < units.length - 1) {
23+ value /= 1000;
24+ unit++;
25+ }
26+ const shown = unit === 0 || value >= 100 ? Math.round(value) : Number(value.toFixed(1));
27+ return `${shown} ${units[unit]}`;
28+}
29+
30+/** `sha256:3f2a…` shortened to `3f2a9c1b7d0e` for lists. */
31+export function shortDigest(digest: string): string {
32+ return digest.replace(/^sha256:/, "").slice(0, 12);
33+}
34+
35+/** The host packages are published to and installed from. */
36+export function registryHost(address: string): string {
37+ return address.split("/")[0] || "g1t.sh";
38+}
39+
40+/**
41+ * How to log in and install `pkg` at `version` (a tag or version) with its
42+ * tool. `you` stands in the username a login takes.
43+ */
44+export function installCommands(pkg: Pick<PackageSummary, "ecosystem" | "address" | "name" | "workspace">, version: string | null, you: string): { login: string; install: string } {
45+ const host = registryHost(pkg.address);
46+ switch (pkg.ecosystem) {
47+ case "container":
48+ return {
49+ login: `docker login ${host} -u ${you}`,
50+ install: `docker pull ${pkg.address}${version ? `:${version}` : ""}`,
51+ };
52+ case "npm":
53+ return {
54+ login: `npm config set @${pkg.workspace}:registry https://${host}/-/npm/`,
55+ install: `npm install @${pkg.workspace}/${pkg.name}${version ? `@${version}` : ""}`,
56+ };
57+ case "composer":
58+ return {
59+ login: `composer config repositories.${pkg.workspace} composer https://${host}/-/composer/${pkg.workspace}/`,
60+ install: `composer require ${pkg.name}${version ? `:${version}` : ""}`,
61+ };
62+ case "cargo":
63+ return {
64+ login: `cargo login --registry ${pkg.workspace}`,
65+ install: `cargo add ${pkg.name} --registry ${pkg.workspace}${version ? ` --vers ${version}` : ""}`,
66+ };
67+ case "go":
68+ return {
69+ login: `go env -w GOPRIVATE=${host}/${pkg.workspace}`,
70+ install: `go get ${pkg.address}${version ? `@${version}` : ""}`,
71+ };
72+ }
73+}
+28−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { servicePath } from "./registry-paths.ts";
5+
6+test("the container registry's paths go to the packages service", () => {
7+ for (const path of [
8+ "/v2",
9+ "/v2/",
10+ "/v2/token",
11+ "/v2/acme/web/manifests/latest",
12+ "/v2/acme/web/api/blobs/uploads/upl_1",
13+ "/v2/acme/web/tags/list",
14+ "/v2/acme/web/referrers/sha256:abc",
15+ ]) {
16+ assert.equal(servicePath(path), "packages", path);
17+ }
18+});
19+
20+test("git goes to repos, and everything else is the site's", () => {
21+ assert.equal(servicePath("/acme/web.git/info/refs"), "git");
22+ assert.equal(servicePath("/acme/web/git-receive-pack"), "git");
23+ // A registry image named like a git endpoint is still the registry's.
24+ assert.equal(servicePath("/v2/acme/info/refs"), "packages");
25+ for (const path of ["/", "/acme", "/acme/web", "/v2x", "/acme/v2", "/acme/-/packages"]) {
26+ assert.equal(servicePath(path), null, path);
27+ }
28+});
+17−0
1+/**
2+ * Which requests to g1t.sh belong to a service other than the site: git
3+ * over HTTPS (the repos service) and the package registries (the packages
4+ * service). `workers/app.ts` hands each to its binding as it is.
5+ */
6+
7+const GIT_PATH = /\/(info\/refs|git-upload-pack|git-receive-pack)$/;
8+/** The container registry: OCI Distribution's `/v2/`, and its token endpoint at `/v2/token`. */
9+const REGISTRY_PATH = /^\/v2(?:\/|$)/;
10+
11+export type ServicePath = "git" | "packages" | null;
12+
13+export function servicePath(pathname: string): ServicePath {
14+ if (REGISTRY_PATH.test(pathname)) return "packages";
15+ if (GIT_PATH.test(pathname)) return "git";
16+ return null;
17+}
+9−8
2929 export const ROADMAP: RoadmapItem[] = [
3030 // --- Work ---------------------------------------------------------------
3131 {
32+ key: "teams",
33+ title: "Teams",
34+ section: "Workspace",
35+ summary: "Groups of members, given roles on repositories together.",
36+ why: "Give a group a role on many repositories at once, mention it, and request its review, instead of adding people one by one.",
37+ plans: ["Teams with members and maintainers", "Repository roles for a team", "@team mentions and review requests"],
38+ },
39+ {
3240 key: "board",
3341 title: "Board",
3442 section: "Workspace",
161169 plans: ["Notes drafted from merged work", "Assets and checksums attached", "Published to the project's page and a feed"],
162170 today: { label: "Commits", path: "commits" },
163171 },
164− {
165− key: "packages",
166− title: "Packages",
167− section: "Workspace",
168− summary: "The workspace's package registry: npm, containers and more.",
169− why: "Publish packages from workflows to g1t's registry, with the same access as the code.",
170− plans: ["npm, OCI containers, Cargo, PyPI and Go modules", "Published from any project's workflows", "Private packages for the workspace"],
171− },
172+
172173 {
173174 key: "flags",
174175 title: "Feature flags",
+4−0
88 contextClient,
99 deploymentsClient,
1010 memoryReviewClient,
11+ packagesClient,
1112 projectsClient,
1213 eventsClient,
1314 guardrailsClient,
3940 const SECURITY = instrumented("security", env.SECURITY);
4041 const CONTEXT = instrumented("context", env.CONTEXT);
4142 const SEARCH = instrumented("search", env.SEARCH);
43+const PACKAGES = instrumented("packages", env.PACKAGES);
4244
4345 export const identity = identityClient(IDENTITY);
4446 /** A person's email addresses and account security: methods of identity. */
6163 export const context = contextClient(CONTEXT);
6264 /** Search across all of g1t, and Explore. */
6365 export const search = searchClient(SEARCH);
66+/** The workspace's registries: container images, and more to come. */
67+export const packages = packagesClient(PACKAGES);
6468 /** Memory candidates and their review: methods of the work service. */
6569 export const memoryReview = memoryReviewClient(WORK);
+3−3
3434 const listed = [...SCOPE_GROUPS.flatMap((group) => group.scopes), ...DANGEROUS_SCOPES];
3535 assert.deepEqual([...listed].sort(), SCOPES.map((row) => row.scope).sort());
3636 assert.equal(new Set(listed).size, listed.length);
37− assert.ok(DANGEROUS_SCOPES.every((scope) => scope.endsWith(":admin")));
37+ assert.ok(DANGEROUS_SCOPES.every((scope) => scope.endsWith(":admin") || scope.endsWith(":delete")));
3838 });
3939
4040 test("ticked boxes store the highest level of each resource", () => {
7070
7171 test("presets are recognised however their scopes are written", () => {
7272 assert.equal(matchingPreset(null), "full");
73− assert.equal(matchingPreset(["repo:read", "code:write", "workflows:write"]), "ci");
73+ assert.equal(matchingPreset(["repo:read", "code:write", "packages:write", "workflows:write"]), "ci");
7474 assert.equal(matchingPreset([...OAUTH_DEFAULT_SCOPES]), "agent");
7575 assert.equal(matchingPreset(["issues:read"]), null);
7676 });
7878 test("a token's access reads plainly", () => {
7979 assert.equal(accessSummary({ scopes: null, legacy: true }), "Legacy · full access");
8080 assert.equal(accessSummary({ scopes: null, legacy: false }), "Full access");
81− assert.equal(accessSummary({ scopes: ["code:read", "code:write", "workflows:write", "repo:read"], legacy: false }), "CI");
81+ assert.equal(accessSummary({ scopes: ["code:read", "code:write", "packages:write", "workflows:write", "repo:read"], legacy: false }), "CI");
8282 assert.equal(accessSummary({ scopes: ["issues:read", "issues:write", "memory:read"], legacy: false }), "2 scopes");
8383 assert.equal(accessSummary({ scopes: [], legacy: false }), "No scopes");
8484 });
+1−1
2727 type ScopeResource,
2828 } from "@g1t/contracts/scopes";
2929
30−const RANK: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, admin: 3 };
30+const RANK: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, delete: 3, admin: 4 };
3131
3232 /** Anything with FormData's getters, so tests can pass a plain map. */
3333 export type FormLike = { get(name: string): unknown; getAll(name: string): unknown[] };
+35−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { confirmsSlug, deletionRefusal, whatGoes } from "./workspace-deletion.ts";
5+
6+const nothing = { repositories: 0, projects: 0, members: 0, billing: null, protected: false };
7+
8+test("what goes lists only what the workspace holds", () => {
9+ assert.deepEqual(whatGoes(nothing, null), []);
10+ assert.deepEqual(whatGoes({ ...nothing, repositories: 1, projects: 2, members: 1 }, 3), [
11+ "1 repository, with its issues, pull requests and workflow runs",
12+ "2 projects",
13+ "3 live apps, taken offline",
14+ "Access for 1 member; their own accounts stay",
15+ ]);
16+ assert.deepEqual(whatGoes({ ...nothing, repositories: 4 }, 0), [
17+ "4 repositories, with their issues, pull requests and workflow runs",
18+ ]);
19+});
20+
21+test("only billing or protection stands in the way", () => {
22+ assert.equal(deletionRefusal("acme", null), null);
23+ assert.equal(deletionRefusal("acme", { ...nothing, repositories: 9, projects: 2 }), null);
24+ assert.equal(deletionRefusal("acme", { ...nothing, billing: "Pay first." }), "Pay first.");
25+ assert.equal(
26+ deletionRefusal("flagon-io", { ...nothing, billing: "Pay first.", protected: true }),
27+ "flagon-io is protected and can never be deleted.",
28+ );
29+});
30+
31+test("the slug confirms in any case, and nothing else does", () => {
32+ assert.ok(confirmsSlug("acme", " ACME "));
33+ assert.ok(!confirmsSlug("acme", ""));
34+ assert.ok(!confirmsSlug("acme", "acme-inc"));
35+});
+39−0
1+/**
2+ * What the danger zone of a workspace's settings says about deleting it:
3+ * what goes with it, and why it cannot go. No Workers or React imports, so
4+ * it can be tested under Node.
5+ */
6+import type { WorkspaceDeletion } from "@g1t/contracts";
7+
8+const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
9+
10+/**
11+ * What deleting the workspace takes with it, one line each, from what
12+ * identity counted and the apps deployments has up (null when unknown).
13+ * Nothing it does not hold is listed.
14+ */
15+export function whatGoes(deletion: WorkspaceDeletion, apps: number | null): string[] {
16+ const lines: string[] = [];
17+ if (deletion.repositories > 0) {
18+ const their = deletion.repositories === 1 ? "its" : "their";
19+ lines.push(`${plural(deletion.repositories, "repository", "repositories")}, with ${their} issues, pull requests and workflow runs`);
20+ }
21+ if (deletion.projects > 0) lines.push(plural(deletion.projects, "project", "projects"));
22+ if (apps && apps > 0) lines.push(`${plural(apps, "live app", "live apps")}, taken offline`);
23+ if (deletion.members > 0) {
24+ lines.push(`Access for ${plural(deletion.members, "member", "members")}; their own accounts stay`);
25+ }
26+ return lines;
27+}
28+
29+/** Why the workspace cannot be deleted, as a sentence, or null. */
30+export function deletionRefusal(slug: string, deletion: WorkspaceDeletion | null): string | null {
31+ if (!deletion) return null;
32+ if (deletion.protected) return `${slug} is protected and can never be deleted.`;
33+ return deletion.billing;
34+}
35+
36+/** Whether what was typed confirms the slug: the slug itself, in any case. */
37+export function confirmsSlug(slug: string, typed: string): boolean {
38+ return typed.trim().toLowerCase() === slug.toLowerCase();
39+}
+2−0
6868 route("-/webhooks", "routes/workspace/webhooks.tsx"),
6969 route("-/secrets", "routes/workspace/secrets.tsx"),
7070 route("-/runners", "routes/workspace/runners.tsx"),
71+ route("-/packages", "routes/workspace/packages.tsx"),
72+ route("-/packages/:ecosystem/*", "routes/workspace/package.tsx"),
7173 route("-/settings", "routes/workspace/settings.tsx"),
7274 route("-/repositories", "routes/workspace/repositories.tsx"),
7375 route("-/agents", "routes/workspace/agents.tsx"),
+14−1
4949 title: "Access tokens",
5050 about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.",
5151 },
52+ packages: {
53+ title: "Packages",
54+ about: "What the workspace publishes and installs: container images today, with the same people, tokens and access as its code.",
55+ },
5256 usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
5357 billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." },
5458 agents: {
9397 const { workspace, role, welcome } = loaderData;
9498 // The sidebar finds the workspace's pages, for everyone, so its pages
9599 // need a title, not the workspace's whole header again.
96− const page = PAGES[useLocation().pathname.split("/-/")[1]?.split("/")[0] ?? ""];
100+ const parts = (useLocation().pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
101+ const page = PAGES[parts[0] ?? ""];
102+ // A page within one (a single package) has its own heading.
103+ if (page && parts.length > 1) {
104+ return (
105+ <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
106+ <Outlet />
107+ </div>
108+ );
109+ }
97110 if (page) {
98111 return (
99112 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
+271−0
1+import { Box, Lock, Package, Trash2 } from "lucide-react";
2+import { Form, Link, data, redirect, useNavigation } from "react-router";
3+
4+import { ECOSYSTEMS, type Ecosystem, type PackageVersion } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/package";
7+import { ConfirmDialog } from "../../components/repo-lifecycle";
8+import { Button, CopyLine, ErrorText, TimeAgo } from "../../components/ui";
9+import { Badge } from "../../components/ui/badge";
10+import { page } from "../../lib/meta";
11+import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages";
12+import { packages } from "../../lib/services.server";
13+import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
14+
15+function ecosystemOf(value: string | undefined): Ecosystem {
16+ if (value && (ECOSYSTEMS as readonly string[]).includes(value)) return value as Ecosystem;
17+ throw data(null, { status: 404 });
18+}
19+
20+export function meta({ params, ...args }: Route.MetaArgs) {
21+ return page(args, { title: `${params["*"]} · Packages · ${params.owner} · g1t` });
22+}
23+
24+export async function loader({ params, context }: Route.LoaderArgs) {
25+ const viewer = getViewer(context);
26+ const ecosystem = ecosystemOf(params.ecosystem);
27+ const name = params["*"] ?? "";
28+ const found = await packages.get(params.owner, ecosystem, name, viewer);
29+ // Not found and not allowed look the same.
30+ if (!found.ok) throw data(null, { status: 404 });
31+ return { detail: found.value, username: viewer?.username ?? "you" };
32+}
33+
34+type Outcome = { error: string | null; message: string | null };
35+
36+export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome | Response> {
37+ assertSameOrigin(request);
38+ const user = requireUser(context, request);
39+ const ecosystem = ecosystemOf(params.ecosystem);
40+ const name = params["*"] ?? "";
41+ const form = await request.formData();
42+ const intent = String(form.get("intent") ?? "");
43+ const fail = (message: string): Outcome => ({ error: message, message: null });
44+ if (intent === "delete-version") {
45+ const version = String(form.get("version") ?? "");
46+ const done = await packages.deleteVersion(user, params.owner, ecosystem, name, version, "web");
47+ return done.ok ? { error: null, message: `Deleted ${shortDigest(version)} and its tags.` } : fail(done.error.message);
48+ }
49+ if (intent === "delete-package") {
50+ const done = await packages.deletePackage(user, params.owner, ecosystem, name, "web");
51+ if (!done.ok) return fail(done.error.message);
52+ return redirect(`/${params.owner}/-/packages`);
53+ }
54+ if (intent === "visibility") {
55+ const visibility = form.get("visibility") === "public" ? "public" : "private";
56+ const done = await packages.set(user, params.owner, ecosystem, name, { visibility }, "web");
57+ return done.ok ? { error: null, message: `It is ${visibility} now.` } : fail(done.error.message);
58+ }
59+ if (intent === "link") {
60+ const repo = String(form.get("repo") ?? "").trim();
61+ const done = repo
62+ ? await packages.set(user, params.owner, ecosystem, name, { link: repo }, "web")
63+ : await packages.set(user, params.owner, ecosystem, name, { unlink: true }, "web");
64+ return done.ok ? { error: null, message: repo ? `Linked to ${repo}.` : "Unlinked." } : fail(done.error.message);
65+ }
66+ return fail("That is not something this page does.");
67+}
68+
69+export default function PackagePage({ loaderData, actionData }: Route.ComponentProps) {
70+ const { detail, username } = loaderData;
71+ const { package: pkg, versions, tags, permissions } = detail;
72+ const outcome = actionData as Outcome | undefined;
73+ const latest = tags.find((tag) => tag.tag === "latest")?.tag ?? tags[0]?.tag ?? null;
74+ const commands = installCommands(pkg, latest, username);
75+ // Signatures and attestations hang off the images they describe.
76+ const images = versions.filter((version) => !version.subject);
77+ const attached = (digest: string) => versions.filter((version) => version.subject === digest);
78+ return (
79+ <div className="space-y-8">
80+ <header className="space-y-3">
81+ <Link to={`/${pkg.workspace}/-/packages`} className="text-sm text-muted hover:text-fg">
82+ Packages
83+ </Link>
84+ <div className="flex flex-wrap items-center gap-3">
85+ <span className="text-faint">{pkg.visibility === "private" ? <Lock size={18} /> : <Package size={18} />}</span>
86+ <h1 className="text-2xl font-semibold tracking-tight">{pkg.name}</h1>
87+ <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge>
88+ <Badge>{pkg.visibility === "private" ? "Private" : "Public"}</Badge>
89+ </div>
90+ <p className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted tabular-nums">
91+ {pkg.repo && (
92+ <Link to={`/${pkg.repo.namespace}/${pkg.repo.name}`} className="inline-flex items-center gap-1 hover:text-fg">
93+ <Box size={13} />
94+ {pkg.repo.namespace}/{pkg.repo.name}
95+ </Link>
96+ )}
97+ <span>{formatBytes(pkg.size)}</span>
98+ <span>
99+ {pkg.downloads.toLocaleString("en-US")} {pkg.downloads === 1 ? "pull" : "pulls"}
100+ </span>
101+ <span>
102+ Updated <TimeAgo at={pkg.updated_at} />
103+ </span>
104+ </p>
105+ {pkg.description && <p className="max-w-2xl text-sm text-fg-soft">{pkg.description}</p>}
106+ </header>
107+
108+ {outcome?.error && <ErrorText>{outcome.error}</ErrorText>}
109+ {outcome?.message && <p className="text-sm text-accent">{outcome.message}</p>}
110+
111+ <section className="space-y-2">
112+ <h2 className="text-sm font-semibold">Pull it</h2>
113+ {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />}
114+ <CopyLine prompt text={commands.install} />
115+ </section>
116+
117+ <section className="space-y-3">
118+ <h2 className="text-sm font-semibold">
119+ Versions <span className="font-normal text-faint">{images.length}</span>
120+ </h2>
121+ {images.length === 0 ? (
122+ <p className="text-sm text-muted">No versions are left.</p>
123+ ) : (
124+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
125+ {images.map((version) => (
126+ <VersionRow key={version.id} version={version} attached={attached(version.digest)} canDelete={permissions.delete} />
127+ ))}
128+ </ul>
129+ )}
130+ </section>
131+
132+ {permissions.admin && <Settings detail={detail} />}
133+ </div>
134+ );
135+}
136+
137+function VersionRow({ version, attached, canDelete }: { version: PackageVersion; attached: PackageVersion[]; canDelete: boolean }) {
138+ return (
139+ <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
140+ <div className="min-w-0 grow space-y-1">
141+ <div className="flex flex-wrap items-center gap-1.5">
142+ {version.tags.length > 0 ? (
143+ version.tags.map((tag) => (
144+ <Badge key={tag} tone={tag === "latest" ? "accent" : "neutral"} className="font-mono">
145+ {tag}
146+ </Badge>
147+ ))
148+ ) : (
149+ <span className="text-xs text-faint">Untagged</span>
150+ )}
151+ <code className="font-mono text-xs text-muted" title={version.digest}>
152+ {shortDigest(version.digest)}
153+ </code>
154+ </div>
155+ <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums">
156+ <span>{formatBytes(version.size)}</span>
157+ {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>}
158+ {attached.length > 0 && (
159+ <span title={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}>
160+ {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")})
161+ </span>
162+ )}
163+ <span>
164+ {version.published_by ? `${version.published_by} · ` : ""}
165+ <TimeAgo at={version.published_at} />
166+ </span>
167+ </p>
168+ </div>
169+ {canDelete && (
170+ <ConfirmDialog
171+ intent="delete-version"
172+ fields={{ version: version.digest }}
173+ title={`Delete ${version.tags[0] ?? shortDigest(version.digest)}?`}
174+ description="Anyone pulling it by this tag or digest gets an error from then on."
175+ submit="Delete version"
176+ busy="Deleting…"
177+ trigger={(open) => (
178+ <button type="button" onClick={open} aria-label="Delete version" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-danger">
179+ <Trash2 size={14} />
180+ </button>
181+ )}
182+ >
183+ <li>Its tags go with it.</li>
184+ <li>Files no other version uses are removed within a day.</li>
185+ </ConfirmDialog>
186+ )}
187+ </li>
188+ );
189+}
190+
191+/** "signature", "SBOM", "attestation", or what the artifact says it is. */
192+function artifactWord(version: PackageVersion): string {
193+ const type = version.artifact_type ?? version.media_type ?? "";
194+ if (/signature|cosign|notary/i.test(type)) return "signature";
195+ if (/spdx|cyclonedx|sbom/i.test(type)) return "SBOM";
196+ if (/in-toto|attestation|provenance/i.test(type)) return "attestation";
197+ return "artifact";
198+}
199+
200+function Settings({ detail }: { detail: Route.ComponentProps["loaderData"]["detail"] }) {
201+ const { package: pkg } = detail;
202+ const busy = useNavigation().state !== "idle";
203+ return (
204+ <section className="space-y-4 rounded-xl border border-line bg-surface p-5">
205+ <h2 className="text-sm font-semibold">Settings</h2>
206+ {pkg.repo ? (
207+ <p className="text-sm text-muted">
208+ Linked to{" "}
209+ <Link to={`/${pkg.repo.namespace}/${pkg.repo.name}`} className="text-fg-soft hover:text-fg">
210+ {pkg.repo.namespace}/{pkg.repo.name}
211+ </Link>
212+ : it has the repository's visibility and roles.
213+ </p>
214+ ) : (
215+ <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
216+ <input type="hidden" name="intent" value="visibility" />
217+ <span className="text-muted">Visibility</span>
218+ <select
219+ name="visibility"
220+ defaultValue={pkg.visibility}
221+ className="h-8 rounded-md border border-line bg-bg px-2 text-sm"
222+ aria-label="Visibility"
223+ >
224+ <option value="private">Private: workspace members</option>
225+ <option value="public">Public: anyone can pull</option>
226+ </select>
227+ <Button type="submit" variant="quiet" disabled={busy}>
228+ Save
229+ </Button>
230+ </Form>
231+ )}
232+ <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
233+ <input type="hidden" name="intent" value="link" />
234+ <label className="text-muted" htmlFor="link-repo">
235+ Repository
236+ </label>
237+ <input
238+ id="link-repo"
239+ name="repo"
240+ defaultValue={pkg.repo?.name ?? ""}
241+ placeholder="none"
242+ className="h-8 w-56 rounded-md border border-line bg-bg px-2 font-mono text-sm placeholder:text-faint"
243+ />
244+ <Button type="submit" variant="quiet" disabled={busy}>
245+ {pkg.repo ? "Change link" : "Link"}
246+ </Button>
247+ <span className="text-xs text-faint">Empty to unlink. A linked package takes the repository's access.</span>
248+ </Form>
249+ {detail.permissions.delete && (
250+ <div className="border-t border-line pt-4">
251+ <ConfirmDialog
252+ intent="delete-package"
253+ title={`Delete ${pkg.name}?`}
254+ description="Every version and tag goes, and anyone pulling it gets an error."
255+ confirm={pkg.name}
256+ submit="Delete package"
257+ busy="Deleting…"
258+ trigger={(open) => (
259+ <Button type="button" variant="danger" onClick={open}>
260+ Delete package
261+ </Button>
262+ )}
263+ >
264+ <li>{detail.versions.length} versions and their tags.</li>
265+ <li>The name can be pushed again afterwards.</li>
266+ </ConfirmDialog>
267+ </div>
268+ )}
269+ </section>
270+ );
271+}
+156−0
1+import { Box, Lock, Package, Search } from "lucide-react";
2+import { Form, Link, data } from "react-router";
3+
4+import { ECOSYSTEMS, type Ecosystem, type PackageSummary } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/packages";
7+import { CopyLine, EmptyState, TimeAgo } from "../../components/ui";
8+import { Badge } from "../../components/ui/badge";
9+import { cn } from "../../lib/cn";
10+import { page } from "../../lib/meta";
11+import { ECOSYSTEM_LABEL, formatBytes } from "../../lib/packages";
12+import { packages } from "../../lib/services.server";
13+import { getViewer, roleIn, unwrap } from "../../lib/session.server";
14+
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Packages · ${params.owner} · g1t` });
17+}
18+
19+export async function loader({ params, context, request }: Route.LoaderArgs) {
20+ const viewer = getViewer(context);
21+ // Members only for now; a workspace's public packages are found through
22+ // their repositories and search.
23+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24+ const url = new URL(request.url);
25+ const asked = url.searchParams.get("type");
26+ const ecosystem = asked && (ECOSYSTEMS as readonly string[]).includes(asked) ? (asked as Ecosystem) : null;
27+ const query = url.searchParams.get("q")?.trim() || null;
28+ const list = unwrap(await packages.list(params.owner, viewer, { ecosystem, query }));
29+ return { list, ecosystem, query, workspace: params.owner.toLowerCase(), username: viewer?.username ?? "you" };
30+}
31+
32+/** The packages a workspace publishes, by registry, with how to start one. */
33+export default function Packages({ loaderData }: Route.ComponentProps) {
34+ const { list, ecosystem, query, workspace, username } = loaderData;
35+ const filtered = ecosystem != null || query != null;
36+ return (
37+ <div className="space-y-6">
38+ <div className="flex justify-end">
39+ <a href="https://docs.g1t.sh/guides/packages/" className="text-sm text-muted hover:text-fg">
40+ How packages work
41+ </a>
42+ </div>
43+
44+ <Form method="get" className="flex flex-wrap items-center gap-2">
45+ <label className="relative min-w-0 grow">
46+ <span className="sr-only">Search packages</span>
47+ <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
48+ <input
49+ name="q"
50+ defaultValue={query ?? ""}
51+ placeholder="Find a package"
52+ className="h-9 w-full rounded-lg border border-line bg-surface pr-3 pl-8 text-sm placeholder:text-faint focus:border-line-strong focus:outline-none"
53+ />
54+ </label>
55+ {ecosystem && <input type="hidden" name="type" value={ecosystem} />}
56+ <nav aria-label="Registry" className="flex flex-wrap gap-1 text-sm">
57+ {[null, ...ECOSYSTEMS].map((option) => {
58+ const params = new URLSearchParams();
59+ if (option) params.set("type", option);
60+ if (query) params.set("q", query);
61+ const href = `/${workspace}/-/packages${params.size ? `?${params}` : ""}`;
62+ return (
63+ <Link
64+ key={option ?? "all"}
65+ to={href}
66+ aria-current={ecosystem === option ? "page" : undefined}
67+ className={cn(
68+ "rounded-md px-2.5 py-1.5 transition-colors",
69+ ecosystem === option ? "bg-raised text-fg" : "text-muted hover:text-fg",
70+ )}
71+ >
72+ {option ? ECOSYSTEM_LABEL[option] : "All"}
73+ </Link>
74+ );
75+ })}
76+ </nav>
77+ </Form>
78+
79+ {list.length === 0 ? (
80+ filtered ? (
81+ <EmptyState title="No packages match">Try another registry, or clear the search.</EmptyState>
82+ ) : (
83+ <FirstPackage workspace={workspace} username={username} />
84+ )
85+ ) : (
86+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
87+ {list.map((pkg) => (
88+ <PackageRow key={pkg.id} pkg={pkg} />
89+ ))}
90+ </ul>
91+ )}
92+ </div>
93+ );
94+}
95+
96+function PackageRow({ pkg }: { pkg: PackageSummary }) {
97+ return (
98+ <li>
99+ <Link
100+ to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`}
101+ className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 transition-colors hover:bg-raised/50"
102+ >
103+ <span className="text-faint">{pkg.visibility === "private" ? <Lock size={15} /> : <Package size={15} />}</span>
104+ <span className="min-w-0 grow">
105+ <span className="flex flex-wrap items-center gap-2">
106+ <span className="truncate font-medium">{pkg.name}</span>
107+ <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge>
108+ {pkg.visibility === "private" && <Badge>Private</Badge>}
109+ </span>
110+ <span className="mt-0.5 block truncate font-mono text-xs text-faint">{pkg.address}</span>
111+ </span>
112+ <span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted tabular-nums">
113+ {pkg.repo && (
114+ <span className="inline-flex items-center gap-1">
115+ <Box size={12} />
116+ {pkg.repo.name}
117+ </span>
118+ )}
119+ {pkg.latest && <span className="font-mono">{pkg.latest}</span>}
120+ <span>
121+ {pkg.versions} {pkg.versions === 1 ? "version" : "versions"}
122+ </span>
123+ <span>{formatBytes(pkg.size)}</span>
124+ <span>
125+ Updated <TimeAgo at={pkg.updated_at} />
126+ </span>
127+ </span>
128+ </Link>
129+ </li>
130+ );
131+}
132+
133+/** With no packages yet: how to push the first image. */
134+function FirstPackage({ workspace, username }: { workspace: string; username: string }) {
135+ return (
136+ <section className="rounded-xl border border-line bg-surface p-6">
137+ <h2 className="font-medium">Push the workspace's first image</h2>
138+ <p className="mt-1 text-sm text-muted">
139+ Log in with a token that may write packages, tag an image under the workspace, and push it. An image named after one of the
140+ workspace's repositories is linked to it and has its access.
141+ </p>
142+ <div className="mt-4 space-y-2">
143+ <CopyLine prompt text={`docker login g1t.sh -u ${username}`} />
144+ <CopyLine prompt text={`docker tag web g1t.sh/${workspace}/web:latest`} />
145+ <CopyLine prompt text={`docker push g1t.sh/${workspace}/web:latest`} />
146+ </div>
147+ <p className="mt-4 text-xs text-faint">
148+ In a workflow, log in with <code className="font-mono">G1T_TOKEN</code>. See{" "}
149+ <a href="https://docs.g1t.sh/guides/containers/" className="underline hover:text-fg">
150+ Containers
151+ </a>
152+ .
153+ </p>
154+ </section>
155+ );
156+}
+1−1
2727 } from "../../lib/session.server";
2828
2929 export function meta({ params, ...args }: Route.MetaArgs) {
30− return page(args, { title: `People · ${params.owner} · g1t` });
30+ return page(args, { title: `Members · ${params.owner} · g1t` });
3131 }
3232
3333 export async function loader({ params, context, request }: Route.LoaderArgs) {
+59−34
44 import {
55 RENAME_COOLDOWN_HOURS,
66 SLUG_HOLD_DAYS,
7+ WORKSPACE_RESTORE_DAYS,
78 type Workspace,
89 type WorkspaceDeletion,
910 isValidNamespace,
2627 import { FieldDescription, FieldLabel, Field as FormField } from "../../components/ui/field";
2728 import { InputAddon, InputGroup, Input as TextInput } from "../../components/ui/input";
2829 import { readAvatarUpload } from "../../lib/avatar-upload";
29−import { identity } from "../../lib/services.server";
30+import { deployments, identity } from "../../lib/services.server";
3031 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
3132 import { forgetWorkspace } from "../../lib/workspace-choice";
33+import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion";
3234
3335 export function meta({ params, ...args }: Route.MetaArgs) {
3436 return page(args, { title: `Settings · ${params.owner} · g1t` });
5658 ? { slug, available: result.value, message: result.value ? null : "That address is not available." }
5759 : { slug, available: false, message: result.error.message };
5860 }
59− // What stands in the way of deleting it, shown before anyone types.
61+ // What deleting it would take with it, and anything in the way, shown
62+ // before anyone types. Its apps are counted by deployments.
6063 let deletion: WorkspaceDeletion | null = null;
64+ let apps: number | null = null;
6165 if (viewer) {
62− const found = await identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null);
66+ const [found, usage] = await Promise.all([
67+ identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null),
68+ deployments.usage(params.owner, viewer).catch(() => null),
69+ ]);
6370 deletion = found?.ok ? found.value : null;
71+ apps = usage?.ok ? usage.value.apps : null;
6472 }
65− return { workspace, check, deletion };
73+ return { workspace, check, deletion, apps };
6674 }
6775
6876 export async function action({ request, params, context }: Route.ActionArgs) {
8492 }
8593 // A new address: identity checks the owner, the name and the cooldown,
8694 // and keeps the old one as a redirect.
87− // Deletion: identity checks the owner, the typed name, that nothing is
88− // left in it and that billing has settled it.
95+ // Deletion: identity checks the owner, the typed name, that it is not
96+ // protected and that billing has settled it. Everything in it goes with
97+ // it; the owner goes back to their own home, where it is no longer listed.
8998 if (intent === "delete") {
9099 const result = await identity.deleteWorkspace(user, params.owner, String(form.get("confirm") ?? ""));
91100 if (!result.ok) return { deleteError: result.error.message };
149158 <DeleteAction
150159 workspace={workspace}
151160 deletion={loaderData.deletion}
161+ apps={loaderData.apps}
152162 error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined}
153163 />
154164 </DangerZone>
156166 );
157167 }
158168
159−/** Why a workspace cannot be deleted yet, as a sentence, or null. */
160−function blockedBy(slug: string, deletion: WorkspaceDeletion | null): string | null {
161− if (!deletion) return null;
162− const held: string[] = [];
163− const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
164− if (deletion.repositories > 0) held.push(plural(deletion.repositories, "repository", "repositories"));
165− if (deletion.projects > 0) held.push(plural(deletion.projects, "project", "projects"));
166− if (held.length) {
167− return `${slug} still holds ${held.join(" and ")}. Transfer each repository to another workspace or delete it first, under Repositories. Recently deleted repositories are removed with the workspace.`;
168− }
169− return deletion.billing;
170−}
171−
172169 /**
173− * Deleting the workspace, once it holds nothing and billing has settled
174− * it. Owners only, as the whole page is; typed out to confirm.
170+ * Deleting the workspace, with everything in it, in one step. Owners only,
171+ * as the whole page is; typed out to confirm. It is kept for
172+ * `WORKSPACE_RESTORE_DAYS`, when support can restore it. A protected
173+ * workspace says so instead of offering the button.
175174 */
176175 function DeleteAction({
177176 workspace,
178177 deletion,
178+ apps,
179179 error,
180180 }: {
181181 workspace: Workspace;
182182 deletion: WorkspaceDeletion | null;
183+ apps: number | null;
183184 error?: string;
184185 }) {
185186 const [open, setOpen] = useState(Boolean(error));
186187 const [confirm, setConfirm] = useState("");
187188 const navigation = useNavigation();
188189 const deleting = navigation.state !== "idle" && navigation.formData?.get("intent") === "delete";
189− const blocked = blockedBy(workspace.slug, deletion);
190+ const refusal = deletionRefusal(workspace.slug, deletion);
191+ const goes = deletion ? whatGoes(deletion, apps) : [];
192+ if (deletion?.protected) {
193+ return (
194+ <DangerAction title="Delete this workspace" action={null}>
195+ <span role="status">{refusal}</span>
196+ </DangerAction>
197+ );
198+ }
190199 return (
191200 <DangerAction
192201 title="Delete this workspace"
193202 action={
194− <Button type="button" variant="danger" disabled={Boolean(blocked)} onClick={() => setOpen(true)}>
203+ <Button type="button" variant="danger" disabled={Boolean(refusal)} onClick={() => setOpen(true)}>
195204 Delete workspace
196205 </Button>
197206 }
198207 >
199− {blocked ? (
200− <span role="status">{blocked}</span>
208+ {refusal ? (
209+ <span role="status">{refusal}</span>
201210 ) : (
202− <>Its members, tokens, webhooks, integrations and secrets are removed. Statements and the audit log are kept.</>
211+ <>
212+ Everything in it goes with it: repositories, projects, apps, members' access and tokens. It is kept for{" "}
213+ {WORKSPACE_RESTORE_DAYS} days, and support can restore it until then.
214+ </>
203215 )}
204216 <AlertDialog
205217 open={open}
213225 <input type="hidden" name="intent" value="delete" />
214226 <AlertDialogHeader>
215227 <AlertDialogTitle>Delete {workspace.slug}?</AlertDialogTitle>
216− <AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
228+ <AlertDialogDescription>
229+ Everything in it goes now. For {WORKSPACE_RESTORE_DAYS} days, support can restore all of it; after
230+ that it is gone for good.
231+ </AlertDialogDescription>
217232 </AlertDialogHeader>
233+ {goes.length > 0 ? (
234+ <div className="grid gap-1.5">
235+ <p className="text-sm font-medium">What goes with it</p>
236+ <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
237+ {goes.map((line) => (
238+ <li key={line}>{line}</li>
239+ ))}
240+ </ul>
241+ </div>
242+ ) : null}
218243 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
219− <li>Everyone loses access to it, and its access tokens stop working at once.</li>
220− <li>Its webhooks, integrations, workspace secrets and memory are removed.</li>
244+ <li>Everyone loses access to it at once, and its access tokens stop working.</li>
245+ <li>Its pages, repositories and apps are no longer found, and nothing builds or runs for it.</li>
221246 <li>
222247 Anything it owes is charged to its card now, and its plan ends today. Its statements, invoices and
223248 audit log are kept.
224249 </li>
225250 <li>
251+ To get it back within {WORKSPACE_RESTORE_DAYS} days, an owner writes to support. After that its
252+ repositories, projects, apps, webhooks, integrations, secrets and memory are removed for good.
253+ </li>
254+ <li>
226255 The name <span className="font-mono text-fg">{workspace.slug}</span> is never given to another
227256 workspace. Repositories that were transferred out keep redirecting from it.
228257 </li>
246275 <ErrorText>{error}</ErrorText>
247276 <AlertDialogFooter>
248277 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
249− <Button
250− type="submit"
251− variant="danger"
252− disabled={confirm.trim().toLowerCase() !== workspace.slug || deleting}
253− >
278+ <Button type="submit" variant="danger" disabled={!confirmsSlug(workspace.slug, confirm) || deleting}>
254279 {deleting ? "Deleting…" : "Delete workspace"}
255280 </Button>
256281 </AlertDialogFooter>
+21−2
11 import { createRequestHandler } from "react-router";
22
33 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
4+import { servicePath } from "../app/lib/registry-paths";
45
56 const requestHandler = createRequestHandler(
67 () => import("virtual:react-router/server-build"),
78 import.meta.env.MODE,
89 );
910
10−const GIT_PATH = /\/(info\/refs|git-upload-pack|git-receive-pack)$/;
1111 /** An uploaded avatar, by the SHA-256 of its bytes. */
1212 const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/;
1313 /** The only types identity stores, having checked each image's bytes. */
3333 // Its answer goes back to the git client as it is: a repository under a
3434 // renamed workspace's old name answers with a 301, which git follows and
3535 // must see, so the redirect is never followed here.
36− if (GIT_PATH.test(pathname)) {
36+ // The container registry (`docker login g1t.sh`) is the packages
37+ // service's, handed over the same way.
38+ const service = servicePath(pathname);
39+ if (service === "git") {
3740 return proxyGit(env, request);
3841 }
42+ if (service === "packages") {
43+ return proxyPackages(env, request);
44+ }
3945 const avatar = AVATAR_PATH.exec(pathname);
4046 if (avatar) {
4147 return serveAvatar(env, ctx, request, avatar[1]);
121127 }
122128
123129 /**
130+ * A registry request, answered by the packages service as it is: its
131+ * redirects (a large blob sent to storage) go back to the client, which
132+ * follows them itself.
133+ */
134+async function proxyPackages(env: Env, request: Request): Promise<Response> {
135+ const started = Date.now();
136+ const answer = await env.PACKAGES.fetch(new Request(request, { redirect: "manual" }));
137+ const response = new Response(answer.body, answer);
138+ response.headers.append("server-timing", `packages;dur=${Date.now() - started}`);
139+ return response;
140+}
141+
142+/**
124143 * An uploaded avatar. Its address is its hash, so it never changes and is
125144 * kept for good. It is served as nothing but an image: the stored type,
126145 * no sniffing, and a policy that lets nothing in it run.
+2−0
66 IDENTITY: ServiceBinding;
77 /** Also serves git over HTTPS through `fetch`. */
88 REPOS: ServiceBinding & { fetch(request: Request): Promise<Response> };
9+ /** Also serves the container registry (`/v2/`) through `fetch`. */
10+ PACKAGES: ServiceBinding & { fetch(request: Request): Promise<Response> };
911 WORK: ServiceBinding;
1012 RUNNER: RunnerApi;
1113 BILLING: ServiceBinding;
+2−0
2626 "services": [
2727 { "binding": "IDENTITY", "service": "g1t-identity" },
2828 { "binding": "REPOS", "service": "g1t-repos" },
29+ // The package registries, on g1t.sh/v2/ (workers/app.ts).
30+ { "binding": "PACKAGES", "service": "g1t-packages" },
2931 { "binding": "WORK", "service": "g1t-work" },
3032 { "binding": "RUNNER", "service": "g1t-runner" },
3133 { "binding": "BILLING", "service": "g1t-billing" },
+2−0
7272 Git,
7373 /// g1t.sh itself: settings changed on its pages.
7474 Web,
75+ /// The package registries: `docker push`, `npm publish` and the like.
76+ Registry,
7577 }
7678
7779 /// The actor of an entry, from whoever made the request.
+22−0
10801080 pub free_private_storage_bytes: i64,
10811081 /// The last daily measure of the workspace's private repositories.
10821082 pub private_storage_bytes: i64,
1083+ /// On a paid plan (not Free): storage past the free amounts below is
1084+ /// charged, so nothing is refused for it.
1085+ #[serde(default)]
1086+ pub has_plan: bool,
1087+ /// Package storage free for every workspace, public and private: past
1088+ /// it, the plan pays for it and a free workspace's pushes are refused.
1089+ #[serde(default)]
1090+ pub package_public_free_bytes: i64,
1091+ #[serde(default)]
1092+ pub package_private_free_bytes: i64,
10831093 /// What g1t's open-source pool paid for the workspace this month.
10841094 pub oss_paid_micros: i64,
10851095 /// Deploy build time this month, every second of it metered.
22452255 pub session: String,
22462256 }
22472257
2258+/// `admin_log`: a staff change another service made to a workspace, kept
2259+/// in sudo's audit log with billing's own (`admin_audit`). For identity's
2260+/// restores and purges of deleted workspaces. Returns `bool`.
2261+#[derive(Debug, Serialize, Deserialize)]
2262+pub struct AdminLogArgs {
2263+ pub workspace: String,
2264+ pub action: String,
2265+ pub detail: String,
2266+ /// The staff member's email.
2267+ pub by: String,
2268+}
2269+
22482270 /// `close_workspace`: settles a workspace that is about to be deleted.
22492271 /// Owners only. Refused while it has an invoice that failed, while it
22502272 /// holds prepaid credit, or while it owes money it cannot be charged for
+64−0
365365 /// RFC 3339: when it is purged unless restored first.
366366 #[serde(default)]
367367 pub purge_after: String,
368+ /// It went with its workspace (`workspace.deleting`). Services that
369+ /// handle the workspace as a whole (deployments pauses its apps rather
370+ /// than taking them down) leave this one to that.
371+ #[serde(default)]
372+ pub with_workspace: bool,
368373 }
369374
370375 /// `repo.restored`: a deleted repository is back, at its path, as it was.
377382 pub namespace: String,
378383 pub name: String,
379384 pub is_private: bool,
385+ /// It came back with its workspace (`workspace.restored`).
386+ #[serde(default)]
387+ pub with_workspace: bool,
380388 }
381389
382390 /// `repo.purged`: a deleted repository is gone for good, its git data
475483 pub slug: String,
476484 }
477485
486+/// `workspace.deleting`: an owner deleted a workspace, and it can be
487+/// restored by g1t's staff until `purge_after`. Nobody can reach it in the
488+/// meantime. Services hide what they keep for it and stop what runs for it,
489+/// keeping their rows: repos deletes its repositories softly (each with a
490+/// `repo.deleted` whose `with_workspace` is set), deployments pauses its
491+/// apps, search drops it from results. `workspace.restored` undoes exactly
492+/// that; once `purge_after` passes, `workspace.deleted` follows and
493+/// services purge as for any deleted workspace.
494+#[derive(Clone, Debug, Serialize, serde::Deserialize)]
495+#[serde(rename_all = "camelCase")]
496+pub struct WorkspaceDeleting {
497+ pub workspace_id: String,
498+ pub slug: String,
499+ /// The username of the owner who deleted it.
500+ pub by: String,
501+ /// RFC 3339: when it is purged unless restored first.
502+ pub purge_after: String,
503+}
504+
505+/// `workspace.restored`: staff brought a deleted workspace back, with its
506+/// members and tokens. Services undo what they did on `workspace.deleting`,
507+/// and only that: a repository deleted on its own before stays deleted.
508+#[derive(Clone, Debug, Serialize, serde::Deserialize)]
509+#[serde(rename_all = "camelCase")]
510+pub struct WorkspaceRestored {
511+ pub workspace_id: String,
512+ pub slug: String,
513+}
514+
478515 /// `user.updated`: an account was made, or changed what its profile shows
479516 /// (name, bio, avatar). Nothing private: ask identity for the profile.
480517 #[derive(Debug, Serialize, serde::Deserialize)]
539576 pub entry_id: String,
540577 }
541578
579+/// The payload of `package.published`, `package.version_deleted`,
580+/// `package.deleted` and `package.visibility_changed`; each uses the fields
581+/// that apply to it. `repo_id` is the repository the package is linked to.
582+#[derive(Clone, Debug, Default, Serialize)]
583+#[serde(rename_all = "camelCase")]
584+pub struct PackageEvent {
585+ pub package_id: String,
586+ pub workspace: String,
587+ pub ecosystem: String,
588+ pub name: String,
589+ pub repo_id: Option<String>,
590+ /// The version published or deleted: for a container image, its
591+ /// manifest's digest.
592+ #[serde(skip_serializing_if = "Option::is_none")]
593+ pub version: Option<String>,
594+ #[serde(skip_serializing_if = "Option::is_none")]
595+ pub digest: Option<String>,
596+ #[serde(skip_serializing_if = "Option::is_none")]
597+ pub size: Option<u64>,
598+ /// On publish: the tags that now point to the version.
599+ #[serde(skip_serializing_if = "Option::is_none")]
600+ pub tags: Option<Vec<String>>,
601+ /// On `package.visibility_changed`: `public` or `private`.
602+ #[serde(skip_serializing_if = "Option::is_none")]
603+ pub visibility: Option<String>,
604+}
605+
542606 /// `queue.changed`: a repository's merge queue gained, lost or settled an
543607 /// entry, so the next batch may be ready to test.
544608 #[derive(Debug, Serialize)]
+125−40
375375 }
376376
377377 /// `delete_workspace`: owners only, and only a person. `confirm` must be
378−/// the workspace's slug, typed out. Refused while the workspace still
379−/// holds repositories or projects, or while billing cannot settle it
380−/// (`close_workspace`). Removes its memberships, its access tokens and its
381−/// old-slug redirects; billing's ledger and the audit log keep its
382−/// history. The slug is never given to another workspace; the person
383−/// whose username it is may make a workspace of that name again.
384−/// Publishes `workspace.deleted`. Returns `Outcome<bool>`.
378+/// the workspace's slug, typed out. Refused for a protected workspace
379+/// ([`protected_names`]), whoever asks, and while billing cannot settle it
380+/// (`close_workspace`). Everything in it goes with it at once: nobody can
381+/// reach it, its tokens stop working, its pages are not found, and its
382+/// repositories, projects and apps are deleted with it. It is kept for
383+/// [`WORKSPACE_RESTORE_DAYS`] so g1t's staff can restore it, then purged:
384+/// its memberships, access tokens and old-slug redirects go, and billing's
385+/// ledger and the audit log keep its history. The slug is never given to
386+/// another workspace; the person whose username it is may make a workspace
387+/// of that name again once it is purged. Publishes `workspace.deleting`,
388+/// and `workspace.deleted` at the purge. Returns `Outcome<bool>`.
385389 ///
386390 /// `check_workspace_deletion` takes the same arguments (with `confirm`
387−/// ignored) and says what stands in the way, changing nothing. Returns
388−/// `Outcome<WorkspaceDeletion>`.
391+/// ignored) and says what would go and whether anything stands in the way,
392+/// changing nothing. Returns `Outcome<WorkspaceDeletion>`.
389393 #[derive(Debug, Serialize, Deserialize)]
390394 pub struct DeleteWorkspaceArgs {
391395 pub actor: User,
397401 pub surface: Option<crate::audit::Surface>,
398402 }
399403
400−/// What stands between a workspace and its deletion. Nothing does when
401−/// both counts are zero and `billing` is null.
404+/// What deleting a workspace takes with it, and what stands in the way.
405+/// Nothing does when `billing` is null and it is not `protected`.
402406 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
403407 pub struct WorkspaceDeletion {
408+ /// Its live repositories, which are deleted with it.
404409 pub repositories: u32,
410+ /// Its projects, hidden with it.
405411 pub projects: u32,
412+ #[serde(default)]
413+ pub members: u32,
406414 /// Why billing cannot close the workspace yet, in words for its owner.
407415 pub billing: Option<String>,
416+ /// It can never be deleted, by anyone ([`protected_names`]).
417+ #[serde(default)]
418+ pub protected: bool,
408419 }
409420
410421 impl WorkspaceDeletion {
411422 pub fn blocked(&self) -> bool {
412− self.repositories > 0 || self.projects > 0 || self.billing.is_some()
423+ self.protected || self.billing.is_some()
413424 }
414425
415− /// Why the workspace cannot be deleted yet, as one sentence, or `None`.
426+ /// Why the workspace cannot be deleted, as one sentence, or `None`.
416427 pub fn reason(&self, slug: &str) -> Option<String> {
417− let plural = |n: u32, one: &str, many: &str| {
418− format!("{n} {}", if n == 1 { one } else { many })
419− };
420− let mut held = Vec::new();
421− if self.repositories > 0 {
422− held.push(plural(self.repositories, "repository", "repositories"));
423− }
424− if self.projects > 0 {
425− held.push(plural(self.projects, "project", "projects"));
426− }
427− if !held.is_empty() {
428− return Some(format!(
429− "{slug} still holds {}. Transfer them to another workspace first.",
430− held.join(" and ")
431− ));
428+ if self.protected {
429+ return Some(protected_refusal(slug));
432430 }
433431 self.billing.clone()
434432 }
435433 }
436434
435+/// How long a deleted workspace is kept, for staff to restore, before it is
436+/// purged.
437+pub const WORKSPACE_RESTORE_DAYS: u64 = 30;
438+
439+/// Workspaces nobody can delete, whatever identity's `PROTECTED_WORKSPACES`
440+/// says: Flagon's, which runs g1t.
441+pub const ALWAYS_PROTECTED: &[&str] = &["flagon-io"];
442+
443+/// The protected workspaces: `configured` (comma-separated slugs or
444+/// workspace ids, as identity's `PROTECTED_WORKSPACES` holds them), and
445+/// [`ALWAYS_PROTECTED`] whatever it says, so an empty or missing variable
446+/// still protects them. Lowercased, without duplicates.
447+pub fn protected_names(configured: Option<&str>) -> Vec<String> {
448+ let mut names: Vec<String> = Vec::new();
449+ let given = configured.unwrap_or_default().split(',');
450+ for name in ALWAYS_PROTECTED.iter().copied().chain(given) {
451+ let name = name.trim().to_lowercase();
452+ if !name.is_empty() && !names.contains(&name) {
453+ names.push(name);
454+ }
455+ }
456+ names
457+}
458+
459+/// Why a protected workspace is not deleted, purged or acted on.
460+pub fn protected_refusal(slug: &str) -> String {
461+ format!("{slug} is protected and can never be deleted.")
462+}
463+
464+/// `admin_deleted_workspaces` takes no arguments (`{}`) and returns
465+/// `Vec<DeletedWorkspace>`, newest first. Staff only.
466+///
467+/// A workspace an owner deleted, kept until `purge_after` for staff to
468+/// restore.
469+#[derive(Clone, Debug, Serialize, Deserialize)]
470+#[serde(rename_all = "camelCase")]
471+pub struct DeletedWorkspace {
472+ pub workspace_id: String,
473+ pub slug: String,
474+ pub name: String,
475+ /// RFC 3339.
476+ pub deleted_at: String,
477+ /// The username of the owner who deleted it.
478+ pub deleted_by: String,
479+ /// RFC 3339: when it is purged unless restored first.
480+ pub purge_after: String,
481+ /// What went with it, counted when it was deleted.
482+ pub went: WorkspaceDeletion,
483+ /// Whether staff can still restore it.
484+ pub restorable: bool,
485+}
486+
487+/// `admin_restore_workspace` and `admin_purge_workspace`: staff restore a
488+/// deleted workspace within [`WORKSPACE_RESTORE_DAYS`], or purge it now.
489+/// `staff` is who, for the audit logs. Purging needs `confirm`, the slug
490+/// typed out, and is refused for a protected workspace. Restoring publishes
491+/// `workspace.restored`; purging, `workspace.deleted`. Both return
492+/// `Outcome<bool>`.
493+#[derive(Debug, Serialize, Deserialize)]
494+#[serde(rename_all = "camelCase")]
495+pub struct AdminDeletedWorkspaceArgs {
496+ pub workspace_id: String,
497+ pub staff: String,
498+ #[serde(default)]
499+ pub confirm: String,
500+}
501+
437502 /// `transfer_repo_scopes`: a repository moved from `from` to `to`; the
438503 /// tokens of agents at work on it are kept pointing at it. For repos'
439504 /// `transfer`. Returns `bool`.
700765 pub created_at: String,
701766 /// Owners first, then by username.
702767 pub members: Vec<AdminMember>,
768+ /// It can never be deleted ([`protected_names`]).
769+ #[serde(default)]
770+ pub protected: bool,
703771 }
704772
705773 /// A member of a workspace, as staff see them.
13051373
13061374 #[cfg(test)]
13071375 mod deletion_tests {
1308− use super::WorkspaceDeletion;
1376+ use super::{WorkspaceDeletion, protected_names};
13091377
13101378 #[test]
1311− fn says_what_is_left_to_move() {
1312− let clear = WorkspaceDeletion::default();
1313− assert!(!clear.blocked());
1314− assert_eq!(clear.reason("acme"), None);
1315− let held = WorkspaceDeletion {
1379+ fn only_billing_or_protection_stands_in_the_way() {
1380+ let clear = WorkspaceDeletion {
13161381 repositories: 2,
13171382 projects: 1,
1318− billing: Some("Pay first.".into()),
1383+ members: 3,
1384+ ..WorkspaceDeletion::default()
13191385 };
1320− assert!(held.blocked());
1321− assert_eq!(
1322− held.reason("acme").as_deref(),
1323− Some("acme still holds 2 repositories and 1 project. Transfer them to another workspace first.")
1324− );
1386+ assert!(!clear.blocked());
1387+ assert_eq!(clear.reason("acme"), None);
13251388 let owing = WorkspaceDeletion {
13261389 billing: Some("Pay first.".into()),
13271390 ..WorkspaceDeletion::default()
13281391 };
1392+ assert!(owing.blocked());
13291393 assert_eq!(owing.reason("acme").as_deref(), Some("Pay first."));
1394+ let protected = WorkspaceDeletion {
1395+ billing: Some("Pay first.".into()),
1396+ protected: true,
1397+ ..WorkspaceDeletion::default()
1398+ };
1399+ assert!(protected.blocked());
1400+ assert_eq!(
1401+ protected.reason("flagon-io").as_deref(),
1402+ Some("flagon-io is protected and can never be deleted.")
1403+ );
1404+ }
1405+
1406+ #[test]
1407+ fn flagon_is_protected_whatever_the_variable_says() {
1408+ assert_eq!(protected_names(None), ["flagon-io"]);
1409+ assert_eq!(protected_names(Some("")), ["flagon-io"]);
1410+ assert_eq!(protected_names(Some(" , ")), ["flagon-io"]);
1411+ assert_eq!(
1412+ protected_names(Some("Flagon-IO, acme ,wsp_1")),
1413+ ["flagon-io", "acme", "wsp_1"]
1414+ );
13301415 }
13311416 }
+1−0
2020 mod ids;
2121 mod names;
2222 mod outcome;
23+pub mod packages;
2324 pub mod projects;
2425 pub mod repos;
2526 pub mod runners;
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.