This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
// Package api holds the wire format shared by pages-server and the pages CLI.
//
// It must depend on the standard library only. cmd/pages imports this package,
// and cmd/pages/deps_test.go fails the build if any server-side dependency
// (SQLite driver, database/sql, internal/store, ...) reaches the CLI through it.
package api
import (
"errors"
"fmt"
)
// Code is a machine-readable error code. Clients switch on these; the
// accompanying message is for humans and may change.
type Code string
const (
CodeBadRequest Code = "bad_request"
CodeUnauthorized Code = "unauthorized"
CodeForbidden Code = "forbidden"
CodeNotFound Code = "not_found"
CodeMethodNotAllowed Code = "method_not_allowed"
CodeConflict Code = "conflict"
CodePayloadTooLarge Code = "payload_too_large"
CodeRateLimited Code = "rate_limited"
CodeInternal Code = "internal"
CodeUnavailable Code = "unavailable"
// Domain-specific codes.
CodeProjectExists Code = "project_exists"
CodeInvalidProjectName Code = "invalid_project_name"
CodeInvalidPath Code = "invalid_path"
CodeDigestMismatch Code = "digest_mismatch"
CodeSizeMismatch Code = "size_mismatch"
CodeDeploymentNotReady Code = "deployment_not_ready"
CodeDeploymentActive Code = "deployment_active"
CodeBlobsMissing Code = "blobs_missing"
CodeLimitExceeded Code = "limit_exceeded"
)
// Error is the body of every non-2xx management API response:
//
// {"error":{"code":"deployment_not_ready","message":"...","details":{...}}}
type Error struct {
Code Code `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
}
// ErrorEnvelope wraps Error so the JSON has a single top-level "error" key.
type ErrorEnvelope struct {
Error Error `json:"error"`
}
func (e *Error) Error() string {
if e.Message == "" {
return string(e.Code)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// Errorf builds an *Error with a formatted message.
func Errorf(code Code, format string, args ...any) *Error {
return &Error{Code: code, Message: fmt.Sprintf(format, args...)}
}
// WithDetail attaches a detail key and returns the receiver for chaining.
func (e *Error) WithDetail(key string, val any) *Error {
if e.Details == nil {
e.Details = make(map[string]any, 1)
}
e.Details[key] = val
return e
}
// CodeOf reports the Code carried by err, or CodeInternal when err is not an
// *Error. It unwraps, so a wrapped *Error is still recognised.
func CodeOf(err error) Code {
var apiErr *Error
if errors.As(err, &apiErr) {
return apiErr.Code
}
return CodeInternal
}
+70
View File
@@ -0,0 +1,70 @@
package api
import "net/url"
// Version is the API path prefix. It is a constant rather than a client option
// because the CLI and the server are released together; a mismatch is a bug,
// not a configuration.
const Version = "/api/v1"
// Path builders, shared so the client and the server's route table cannot drift
// apart. Every segment that comes from user input is escaped: a project named
// with a slash could otherwise rewrite the request into a different endpoint.
// (Project names are pattern-checked on creation, so this is defence in depth
// against a name that predates a stricter pattern.)
func PathProjects() string { return Version + "/projects" }
func PathProject(name string) string {
return Version + "/projects/" + url.PathEscape(name)
}
func PathProjectKeys(name string) string {
return PathProject(name) + "/keys"
}
func PathKeys() string { return Version + "/keys" }
func PathKey(id string) string {
return Version + "/keys/" + url.PathEscape(id)
}
func PathWhoAmI() string { return Version + "/whoami" }
func PathSystemInfo() string { return Version + "/system/info" }
func PathGC() string { return Version + "/gc" }
func PathFsck() string { return Version + "/fsck" }
func PathDeployments(project string) string {
return PathProject(project) + "/deployments"
}
func PathDeployment(project, id string) string {
return PathDeployments(project) + "/" + url.PathEscape(id)
}
func PathManifest(project, id string) string {
return PathDeployment(project, id) + "/manifest"
}
func PathFinalize(project, id string) string {
return PathDeployment(project, id) + "/finalize"
}
func PathActivate(project, id string) string {
return PathDeployment(project, id) + "/activate"
}
// PathBlob addresses a blob by lowercase hex digest. Blobs are global rather
// than per-project because the content-addressed store deduplicates across
// projects; see the security notes on the blob existence oracle.
func PathBlob(hexDigest string) string {
return Version + "/blobs/" + url.PathEscape(hexDigest)
}
// SiteURL returns where a project is served under path routing.
func SiteURL(base, project string) string {
return base + "/~" + url.PathEscape(project) + "/"
}
+258
View File
@@ -0,0 +1,258 @@
package api
import "time"
// Deployment states, as they appear on the wire.
const (
StatePending = "pending"
StateUploading = "uploading"
StateReady = "ready"
StateFailed = "failed"
StateDeleting = "deleting"
)
// Key scopes, as they appear on the wire.
const (
ScopeAdmin = "admin"
ScopeProject = "project"
)
// ---------------------------------------------------------------- projects
// Project is the server's view of a project.
//
// Every mutable setting also appears in ProjectPatch. Adding a field here that
// cannot be changed afterwards is a deliberate choice, not an oversight: Name
// is immutable because renaming would invalidate every deployed URL and every
// webroot symlink pointing at it.
type Project struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
IndexFile string `json:"index_file"`
NotFoundFile string `json:"not_found_file,omitempty"`
SPAFallback bool `json:"spa_fallback"`
CacheControl string `json:"cache_control"`
RetentionCount int `json:"retention_count"`
RetentionGrace int `json:"retention_grace_s"`
MaxFiles int `json:"max_files"`
MaxFileBytes int64 `json:"max_file_bytes"`
MaxTotalBytes int64 `json:"max_total_bytes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// ActiveDeployment is nil when the project has never been deployed, which
// is why it is a pointer rather than a zero-valued struct.
ActiveDeployment *Deployment `json:"active_deployment,omitempty"`
// URL is where the active deployment is served, when the server knows its
// public base URL.
URL string `json:"url,omitempty"`
}
// CreateProjectRequest creates a project. Everything except Name is optional
// and falls back to the server's defaults.
type CreateProjectRequest struct {
Name string `json:"name"`
Patch *ProjectPatch `json:"config,omitempty"`
}
// ProjectPatch is a partial update.
//
// Every field is a pointer so the server can tell "leave this alone" from "set
// this to the zero value" — without that, PATCH could never clear a custom 404
// document or turn the SPA fallback off.
type ProjectPatch struct {
DisplayName *string `json:"display_name,omitempty"`
IndexFile *string `json:"index_file,omitempty"`
NotFoundFile *string `json:"not_found_file,omitempty"`
SPAFallback *bool `json:"spa_fallback,omitempty"`
CacheControl *string `json:"cache_control,omitempty"`
RetentionCount *int `json:"retention_count,omitempty"`
RetentionGrace *int `json:"retention_grace_s,omitempty"`
MaxFiles *int `json:"max_files,omitempty"`
MaxFileBytes *int64 `json:"max_file_bytes,omitempty"`
MaxTotalBytes *int64 `json:"max_total_bytes,omitempty"`
}
// ProjectList is the paged response for GET /api/v1/projects.
type ProjectList struct {
Projects []Project `json:"projects"`
NextCursor string `json:"next_cursor,omitempty"`
}
// -------------------------------------------------------------------- keys
// Key describes an API key. It never carries the secret: the full token exists
// on the wire exactly once, in CreateKeyResponse.
type Key struct {
ID string `json:"id"`
Scope string `json:"scope"`
Project string `json:"project,omitempty"`
Name string `json:"name,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsed *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// Revoked reports whether the key has been revoked.
func (k Key) Revoked() bool { return k.RevokedAt != nil }
// CreateKeyRequest mints a key. Project is set by the URL for the
// project-scoped endpoint and must be empty otherwise.
type CreateKeyRequest struct {
Name string `json:"name,omitempty"`
// ExpiresAt is absolute, not a duration: the CLI parses "90d" locally so a
// clock skew between client and server cannot silently shift expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// CreateKeyResponse is the only place a full token ever appears.
type CreateKeyResponse struct {
Key Key `json:"key"`
// Token is shown once and never retrievable again. Clients must not log it.
Token string `json:"token"`
}
// KeyList is the response for the key listing endpoints.
type KeyList struct {
Keys []Key `json:"keys"`
}
// WhoAmI describes the caller's own credential.
type WhoAmI struct {
KeyID string `json:"key_id"`
Scope string `json:"scope"`
Project string `json:"project,omitempty"`
Name string `json:"name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ------------------------------------------------------------- deployments
// Deployment is the server's view of one upload.
type Deployment struct {
ID string `json:"id"`
Project string `json:"project"`
State string `json:"state"`
Active bool `json:"active"`
FileCount int `json:"file_count"`
TotalBytes int64 `json:"total_bytes"`
Meta map[string]string `json:"meta,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
FinalizedAt *time.Time `json:"finalized_at,omitempty"`
ActivatedAt *time.Time `json:"activated_at,omitempty"`
// URL is where this deployment is served, set on the response to an
// activation when the server knows its public base URL. It is the project's
// URL: only the active deployment has one, since there are no per-version
// preview addresses.
URL string `json:"url,omitempty"`
// Files is populated only by GET .../deployments/{id}?files=true.
Files []FileEntry `json:"files,omitempty"`
}
// FileEntry is one line of a manifest. Digest is lowercase hex; the server
// stores the raw 32 bytes, and hex exists only at this boundary.
type FileEntry struct {
Path string `json:"path"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// CreateDeploymentRequest starts a deployment.
type CreateDeploymentRequest struct {
Meta map[string]string `json:"meta,omitempty"`
}
// ManifestRequest declares the complete file list of a deployment.
type ManifestRequest struct {
Files []FileEntry `json:"files"`
}
// ManifestResponse tells the client which blobs the server does not have yet.
//
// Missing is the number that makes content-addressed upload worth having, so
// the CLI prints it: "142 files, 3.1 MiB; 11 new blobs, 402 KiB to upload".
type ManifestResponse struct {
Missing []string `json:"missing"`
MissingBytes int64 `json:"missing_bytes"`
Have int `json:"have"`
FileCount int `json:"file_count"`
TotalBytes int64 `json:"total_bytes"`
}
// BlobResponse acknowledges an uploaded blob.
type BlobResponse struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// DeploymentList is the paged response for the deployment listing endpoint.
type DeploymentList struct {
Deployments []Deployment `json:"deployments"`
NextCursor string `json:"next_cursor,omitempty"`
}
// ------------------------------------------------------------------ system
// SystemInfo is the response for GET /api/v1/system/info.
type SystemInfo struct {
Version string `json:"version"`
UptimeS int64 `json:"uptime_s"`
Projects int64 `json:"projects"`
Deployments int64 `json:"deployments"`
Blobs int64 `json:"blobs"`
CASBytes int64 `json:"cas_bytes"`
LinkMode string `json:"link_mode"`
SchemaVer int `json:"schema_version"`
}
// GCRequest asks for a garbage collection pass.
type GCRequest struct {
DryRun bool `json:"dry_run,omitempty"`
}
// GCStats reports what a collection pass did, or would have done.
//
// On a dry run the blob numbers count what is collectable right now, not what
// deleting the listed deployments would additionally free: nothing was deleted,
// so those blobs are still referenced. The figures are a floor.
type GCStats struct {
DryRun bool `json:"dry_run"`
DeploymentsDeleted int `json:"deployments_deleted"`
BlobsDeleted int `json:"blobs_deleted"`
BytesFreed int64 `json:"bytes_freed"`
}
// FsckRequest asks for a consistency check, optionally correcting what it
// finds.
type FsckRequest struct {
Repair bool `json:"repair,omitempty"`
}
// FsckReport is the result of a consistency check.
type FsckReport struct {
// Blobs is how many were examined, DriftCount how many disagreed with the
// manifests that reference them. Drift lists the first hundred of them,
// because the list is for a person to read.
Blobs int64 `json:"blobs"`
DriftCount int `json:"drift_count"`
Drift []BlobDrift `json:"drift,omitempty"`
Repaired int `json:"repaired"`
}
// BlobDrift is one blob whose stored reference count is not the number of
// manifest entries that name it.
type BlobDrift struct {
Digest string `json:"digest"`
// Stored above Actual only wastes disk. Stored below Actual is the
// dangerous direction: the collector may remove content a deployment still
// needs.
Stored int64 `json:"stored"`
Actual int64 `json:"actual"`
}