mesh/merge-gate head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The tools' token reached everything the administrator may do, so their boundary rested on their own guards. The gitea module now makes a plain account, mesh-tools, with the admin login, gives it write on code, issues and pull requests of every novox repository through one team, and mints the tools' token for it; the admin's old token goes only after the new one is kept. The Go forge bundle sends that token, never the admin login. The forge shows protection rules only to a repository administrator, so every check reads a branch's protection from the branch. mesh-admin stays a ready author, so marks made before the move keep counting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
7.5 KiB
Go
217 lines
7.5 KiB
Go
// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge,
|
|
// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539).
|
|
//
|
|
// Two ways in, by what the manifest hands the bundle:
|
|
//
|
|
// - **as the tools' account** (novox/hq ADR 0365 §2), when MESH_GITEA_TOKEN_FILE names the token the module
|
|
// keeps for it (token.ts mints it, for `mesh-tools`, and keeps it at 0600 with its scopes line beside it). A
|
|
// token whose scopes line does not say it is the tools' account's — one kept from before, the administrator's —
|
|
// is never sent: the call fails, saying so, until the module has minted the account's own. This bundle mints
|
|
// nothing; a token the forge rejects is renewed by the module's next call, and said here.
|
|
// - **as the admin account**, whose password the vault delivered into a file this module owns (novox/hq ADR
|
|
// 0086), by basic authentication, when no token file is named.
|
|
//
|
|
// A credential is read when a call is made and never logged, answered or put in an error.
|
|
package forgehttp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MaxBody is the largest JSON answer read from the forge.
|
|
const MaxBody = 32 << 20
|
|
|
|
// Client is the forge's HTTP interface.
|
|
type Client struct {
|
|
URL string // e.g. http://127.0.0.1:3000
|
|
User string
|
|
PasswordFile string
|
|
// TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login;
|
|
// Account the account its scopes line must name (MESH_GITEA_TOOLS_USER, default mesh-tools).
|
|
TokenFile string
|
|
Account string
|
|
HTTP *http.Client // when nil, a client with Timeout
|
|
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
|
|
}
|
|
|
|
// ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts).
|
|
const ToolsAccount = "mesh-tools"
|
|
|
|
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle
|
|
// handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login.
|
|
func FromEnv() (Client, []string) {
|
|
if file := os.Getenv("MESH_GITEA_TOKEN_FILE"); file != "" {
|
|
c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: file,
|
|
Account: os.Getenv("MESH_GITEA_TOOLS_USER")}
|
|
if c.Account == "" {
|
|
c.Account = ToolsAccount
|
|
}
|
|
if c.URL == "" {
|
|
return c, []string{"MESH_GITEA_URL"}
|
|
}
|
|
return c, nil
|
|
}
|
|
c := Client{
|
|
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
|
|
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
|
|
PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"),
|
|
}
|
|
var missing []string
|
|
for _, kv := range [][2]string{{"MESH_GITEA_URL", c.URL}, {"MESH_GITEA_ADMIN_USER", c.User},
|
|
{"MESH_GITEA_ADMIN_PASSWORD_FILE", c.PasswordFile}} {
|
|
if kv[1] == "" {
|
|
missing = append(missing, kv[0])
|
|
}
|
|
}
|
|
return c, missing
|
|
}
|
|
|
|
// ErrNotFound is the forge answering 404.
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
// StatusError is an answer the forge gave that is not a success, said without the request's credential.
|
|
type StatusError struct {
|
|
Method, Path string
|
|
Status int
|
|
Said string
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
return fmt.Sprintf("the forge answered %d to %s %s: %s", e.Status, e.Method, e.Path, e.Said)
|
|
}
|
|
|
|
func (c *Client) client() *http.Client {
|
|
if c.HTTP != nil {
|
|
return c.HTTP
|
|
}
|
|
timeout := c.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 20 * time.Second
|
|
}
|
|
return &http.Client{Timeout: timeout}
|
|
}
|
|
|
|
func (c *Client) password() (string, error) {
|
|
raw, err := os.ReadFile(c.PasswordFile)
|
|
if err != nil {
|
|
// The path is the manifest's, not a secret; the content never appears.
|
|
return "", fmt.Errorf("the forge's admin password cannot be read from its file: %w", err)
|
|
}
|
|
return strings.TrimSpace(string(raw)), nil
|
|
}
|
|
|
|
// token is the tools' account's token, refused unless its scopes line names that account.
|
|
func (c *Client) token() (string, error) {
|
|
raw, err := os.ReadFile(c.TokenFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err)
|
|
}
|
|
line, err := os.ReadFile(c.TokenFile + ".scopes")
|
|
if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) {
|
|
return "", fmt.Errorf("the token kept at %s is not %s's (novox/hq ADR 0365): the module mints that account's own at its next call; nothing was sent", c.TokenFile, c.Account)
|
|
}
|
|
token := strings.TrimSpace(string(raw))
|
|
if token == "" {
|
|
return "", fmt.Errorf("the token kept at %s is empty; nothing was sent", c.TokenFile)
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// authorize puts the bundle's credential on a request: the tools' token, or the admin login.
|
|
func (c *Client) authorize(req *http.Request) error {
|
|
if c.TokenFile != "" {
|
|
token, err := c.token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+token)
|
|
return nil
|
|
}
|
|
password, err := c.password()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(c.User, password)
|
|
return nil
|
|
}
|
|
|
|
// Do sends one request, with payload as its JSON body when not nil, and answers the body, read up to limit;
|
|
// 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it
|
|
// exactly: a cut body is refused, never read as if it were whole.
|
|
func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) {
|
|
var body io.Reader
|
|
if payload != nil {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.URL+path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.authorize(req); err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
res, err := c.client().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the forge did not answer %s %s: %w", method, path, err)
|
|
}
|
|
defer res.Body.Close()
|
|
got, err := io.ReadAll(io.LimitReader(res.Body, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(got)) > limit {
|
|
return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20)
|
|
}
|
|
switch {
|
|
case res.StatusCode == http.StatusUnauthorized && c.TokenFile != "":
|
|
return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w",
|
|
method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"})
|
|
case res.StatusCode == http.StatusNotFound:
|
|
return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound)
|
|
case res.StatusCode >= 300:
|
|
said := strings.TrimSpace(string(got))
|
|
if len(said) > 300 {
|
|
said = said[:300] + "…"
|
|
}
|
|
return nil, &StatusError{method, path, res.StatusCode, said}
|
|
}
|
|
return got, nil
|
|
}
|
|
|
|
// GetJSON reads one answer into into.
|
|
func (c *Client) GetJSON(ctx context.Context, path string, into any) error {
|
|
return c.SendJSON(ctx, http.MethodGet, path, nil, into)
|
|
}
|
|
|
|
// SendJSON sends one request and reads its answer into into, when into is not nil.
|
|
func (c *Client) SendJSON(ctx context.Context, method, path string, payload, into any) error {
|
|
body, err := c.Do(ctx, method, path, payload, MaxBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if into == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(body, into); err != nil {
|
|
return fmt.Errorf("the forge's answer to %s %s is not readable: %w", method, path, err)
|
|
}
|
|
return nil
|
|
}
|