Files
mesh-catalog/modules/gitea/cmd/package-registry/forge.go
T
jschoubben 4492bc3013 CAT-G2: the packages token is renewed on the forge's refusal, said as a finding, never on a timer
The Go bundle raises packages-token-refused in the module's findings state when the forge
answers 401 to a write; the watcher mints afresh on that key's event and clears it (lead's
ruling on A3n, map-onto-how-the-mesh-is-built: nothing waits on a timer).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:47:26 +01:00

256 lines
8.4 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"gitea/internal/forgehttp"
)
// The forge as this bundle reaches it: its own HTTP interface on the machine (internal/forgehttp, shared with the
// forge bundle beside it), never as the admin account. It reads as the tools' account, whose token carries
// read:package, and writes (a publish, a remove, retention's deletion) as the packages account, whose token carries
// write:package (design 51: each holder keeps its write side as an own secret beside a read-only tool token). The
// TypeScript bundle mints and keeps both tokens (token.ts, packages-account.ts); this bundle mints nothing.
// Forge is the forge's HTTP interface.
type Forge struct {
forgehttp.Client // reads, as the tools' account
// Writer writes into the package registry, as the packages account; nil writes nothing.
Writer *forgehttp.Client
Owner string // the package registry's owner, as the seat serves it
// Type is the registry's package type retention reads: TypeNpm when empty, or TypeGo.
Type string
// Refused says whether the forge refused the write side's token (401), as the finding PackagesTokenRefused in the
// module's findings state: the TypeScript bundle mints a new token on that key's event and clears it (no timer
// looks at the token). Nil says nothing.
Refused func(raised bool)
}
// PackagesTokenRefused is the finding this bundle raises when the forge refuses the packages account's token, and its
// key in the module's findings state (packages-account.ts PACKAGES_TOKEN_REFUSED watches that key).
const (
PackagesTokenRefused = "packages-token-refused"
PackagesTokenRefusedKey = "seat.gitea.packages-token-refused_write"
)
// wrote is a write's answer, said on Refused: a 401 raises the finding, a write the forge took clears it.
func (f *Forge) wrote(err error) error {
if f.Refused == nil {
return err
}
var status *forgehttp.StatusError
switch {
case errors.As(err, &status) && status.Status == http.StatusUnauthorized:
f.Refused(true)
case err == nil:
f.Refused(false)
}
return err
}
// The package types the retention reads: npm packages (the npm seat) and Go modules (the Go seat).
const (
TypeNpm = "npm"
TypeGo = "go"
)
// typ is the package type this forge's retention reads.
func (f *Forge) typ() string {
if f.Type == "" {
return TypeNpm
}
return f.Type
}
// As is the same forge, its retention reading the package type typ.
func (f *Forge) As(typ string) *Forge {
out := *f
out.Type = typ
return &out
}
// ForgeFromEnv is the forge as the manifest hands it to this bundle.
func ForgeFromEnv() (*Forge, error) {
c, missing := forgehttp.FromEnv()
w, writeMissing := forgehttp.WriterFromEnv()
for _, m := range writeMissing {
if m != "MESH_GITEA_URL" {
missing = append(missing, m)
}
}
f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER"), Refused: func(raised bool) {
finding(PackagesTokenRefused, "write", "the forge refused the package seats' write token; the module mints it afresh", raised)
}}
if f.Owner == "" {
missing = append(missing, "MESH_NPM_OWNER")
}
if len(missing) > 0 {
return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest", strings.Join(missing, ", "))
}
return f, nil
}
// errNotFound is the forge answering 404.
var errNotFound = forgehttp.ErrNotFound
// maxBody is the largest answer read from the forge.
const maxBody = forgehttp.MaxBody
// do sends one request without a body and answers the body; 404 is errNotFound.
func (f *Forge) do(ctx context.Context, method, path string) ([]byte, error) {
return f.Do(ctx, method, path, nil, maxBody)
}
// PackageVersion is one version of one package, as the forge lists it.
type PackageVersion struct {
Name string `json:"name"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
const pageSize = 50
// Packages is every package version of the owner of this forge's type, all pages.
func (f *Forge) Packages(ctx context.Context) ([]PackageVersion, error) {
var all []PackageVersion
for page := 1; page <= 1000; page++ {
var batch []PackageVersion
path := fmt.Sprintf("/api/v1/packages/%s?type=%s&page=%d&limit=%d", url.PathEscape(f.Owner), f.typ(), page, pageSize)
if err := f.GetJSON(ctx, path, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < pageSize {
return all, nil
}
}
return nil, errors.New("the forge listed more than 1000 pages of packages; stopped rather than read for ever")
}
func (f *Forge) versionPath(name, version string) string {
return f.packagePath(f.typ(), name, version)
}
// Size is the bytes of one version's files.
func (f *Forge) Size(ctx context.Context, name, version string) (int64, error) {
var files []struct {
Size int64 `json:"size"`
}
if err := f.GetJSON(ctx, f.versionPath(name, version)+"/files", &files); err != nil {
return 0, err
}
var total int64
for _, file := range files {
total += file.Size
}
return total, nil
}
// DistTags is the dist-tags of one package, from the registry's own metadata.
func (f *Forge) DistTags(ctx context.Context, name string) (map[string]string, error) {
var meta struct {
DistTags map[string]string `json:"dist-tags"`
}
path := fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(name))
if err := f.GetJSON(ctx, path, &meta); err != nil {
return nil, err
}
return meta.DistTags, nil
}
// write is the registry's write side, refused when the manifest handed none.
func (f *Forge) write() (*forgehttp.Client, error) {
if f.Writer == nil {
return nil, errors.New("the package registry's write side is not handed to this bundle (MESH_GITEA_PACKAGES_TOKEN_FILE); nothing was written")
}
return f.Writer, nil
}
// Delete removes one version from the registry, as the packages account.
func (f *Forge) Delete(ctx context.Context, name, version string) error {
w, err := f.write()
if err != nil {
return err
}
_, err = w.Do(ctx, http.MethodDelete, f.versionPath(name, version), nil, maxBody)
return f.wrote(err)
}
// Repository is one repository on the forge, as far as reading its manifests needs.
type Repository struct {
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
Empty bool `json:"empty"`
}
// Repositories is every repository the admin sees.
func (f *Forge) Repositories(ctx context.Context) ([]Repository, error) {
var all []Repository
for page := 1; page <= 1000; page++ {
var found struct {
Data []Repository `json:"data"`
}
if err := f.GetJSON(ctx, fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, pageSize), &found); err != nil {
return nil, err
}
all = append(all, found.Data...)
if len(found.Data) < pageSize {
return all, nil
}
}
return nil, errors.New("the forge listed more than 1000 pages of repositories; stopped rather than read for ever")
}
func repoPath(fullName string) string {
owner, name, _ := strings.Cut(fullName, "/")
return url.PathEscape(owner) + "/" + url.PathEscape(name)
}
// Files is every file path on a branch, all pages of the forge's tree.
func (f *Forge) Files(ctx context.Context, repo Repository) ([]string, error) {
var paths []string
seen := 0
for page := 1; page <= 1000; page++ {
var tree struct {
Tree []struct {
Path string `json:"path"`
Type string `json:"type"`
} `json:"tree"`
Truncated bool `json:"truncated"`
TotalCount int `json:"total_count"`
}
path := fmt.Sprintf("/api/v1/repos/%s/git/trees/%s?recursive=true&page=%d&per_page=1000",
repoPath(repo.FullName), url.PathEscape(repo.DefaultBranch), page)
if err := f.GetJSON(ctx, path, &tree); err != nil {
return nil, err
}
for _, e := range tree.Tree {
if e.Type == "blob" {
paths = append(paths, e.Path)
}
}
seen += len(tree.Tree)
if len(tree.Tree) == 0 || !tree.Truncated && (tree.TotalCount == 0 || seen >= tree.TotalCount) {
return paths, nil
}
}
return nil, fmt.Errorf("%s has more than 1000 pages of files; not read whole", repo.FullName)
}
// Raw is one file's content on the repository's default branch.
func (f *Forge) Raw(ctx context.Context, repo Repository, file string) ([]byte, error) {
escaped := strings.Split(file, "/")
for i := range escaped {
escaped[i] = url.PathEscape(escaped[i])
}
return f.do(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/raw/%s?ref=%s",
repoPath(repo.FullName), strings.Join(escaped, "/"), url.QueryEscape(repo.DefaultBranch)))
}