server

package
v0.19.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: Apache-2.0 Imports: 52 Imported by: 0

Documentation

Overview

Package server is the loopback HTTP mux for the web UI and the API (specs/000-product/contracts/api.md). Reads are assembled from the SQLite mirror. Write-through endpoints call Jira and re-read the issue into the mirror. Credentials are needed for those writes and for fetching attachment bytes that are not already on disk.

Authentication is by Host, not by session. A loopback (or *.localhost) request is the CLI user and passes unauthenticated by construction (decision 0003) — that surface stays byte-identical. A DNS-named or otherwise non-loopback Host is the shape `tailscale serve` and `--allow-remote` forward, and it meets a pairing token at one of three gates, each answering 401 pairing_rejected / 403 scope_rejected | forbidden_host on its own inputs and scope door:

  • mirrorGate (mirror_gate.go) — the mirror REST, serve scope
  • the origin passthrough (origin_rest.go) — origin scope
  • the terminal gate (terminal.go) — terminal scope; a serve token can never reach a shell (GDK-863)

So a remote Host without a valid scoped token DOES get 401/403 here; only the loopback surface has nothing to authenticate. A new remote-reachable route inherits no gate for free — it is closed until it names its scope (serveScopeAdmits is default-closed; TestServeScopeIsDefaultClosed).

Index

Constants

View Source
const RefScheme = "gadak://"

RefScheme is the URL form of a cross-workspace pointer. Kept in step with cmd/gadak's refScheme — the CLI writes these and the server reads them.

View Source
const TermBase = termBase

TermBase is termBase exported for the one client outside this package that builds a URL against the surface: the CLI's claim reflection POST (GDK-1158). Same shape as origin.RESTPrefix, and an alias rather than a second literal, so the two names cannot drift.

Variables

View Source
var Version = "0.0.0-dev"

Version is the gadak release string exposed on GET settings/ under runtime. cmd/gadak should assign this from its ldflags version var at startup:

server.Version = version

Until that wiring lands, the default below is what the UI shows.

Functions

func GuardBrowser added in v0.13.0

func GuardBrowser(next http.Handler, ex GuardExempts) http.Handler

GuardBrowser wraps next so Host/Origin checks run before any route. Mount this on the top-level serve mux so routes registered outside Handler (/config.json, /healthz, /api/v1/workspaces, /w/) cannot skip the guard.

func MergedPRLinks(devLinks []store.DevLink, attachments []store.DetailAttachment) json.RawMessage

MergedPRLinks is ListLinkedPRs encoded as the linked_prs JSON array. Empty input returns nil so omitempty callers can drop the field.

func PairedAppOriginExempt added in v0.19.0

func PairedAppOriginExempt(dir func() string) func(*http.Request) bool

PairedAppOriginExempt lets browserGuard's Origin check step aside for the packaged app's webview identity. tauri-plugin-http stamps `Origin: tauri://localhost` on every native fetch (nothing short of the plugin's unsafe-headers feature can omit it), and allowedOrigin rightly rejects non-http(s) schemes — so the packaged phone app was read-only against every serve: each of its POSTs died as forbidden_origin while its GETs sailed through (GDK-1120, measured against a live serve).

Unlike the Host exempts, this one validates the credential itself: AuthorizeMeta must return VerdictAccept for the request's own Bearer. A hostile page in someone else's webview shares the tauri://localhost identity but cannot present a pairing token (a page cannot set Authorization on a WebSocket at all, and a cross-origin fetch with one dies on a preflight this server never answers) — and on a serve with no tokens at all there is no later gate, so "a gate will check it" is not good enough here (see TestTerminalWebviewOriginCannotOpenTheSocket). Terminal paths additionally require a scope that admits the terminal: GDK-863's ruling — a serve token never learns where the shell is — applies to this door too.

func PairedMirrorHostExempt added in v0.17.3

func PairedMirrorHostExempt(dir func() string) func(*http.Request) bool

PairedMirrorHostExempt lets GuardBrowser pass a DNS-named Host for mirror-REST requests while active pairing tokens exist — the same probe shape as PairedOriginHostExempt: an empty-bearer Authorize answers "does the gate have anything to check" without accepting anything. VerdictOff (or an unreadable store, which fails closed) keeps today's forbidden_host, so an unpaired serve never widens for the phone. dir is resolved per request: pairing.json can appear while a serve is running.

func PairedOriginHostExempt added in v0.17.0

func PairedOriginHostExempt(dir func() string) func(*http.Request) bool

PairedOriginHostExempt lets GuardBrowser pass a DNS-named Host — which the rebinding check otherwise rejects — for origin-passthrough requests while active pairing tokens exist. Measured on a real tailnet (GDK-443): tailscale serve forwards the original `<machine>.<tailnet>.ts.net` Host upstream, so without this every paired request died as forbidden_host before the Bearer gate could speak. Authorize with an empty bearer answers "do tokens exist" without accepting anything: VerdictOff (or an error, which fails closed) keeps today's rejection, VerdictReject means pairingGate will demand the Bearer right after this. dir is resolved per request — pairing.json can appear while a serve is running.

func PairedTerminalHostExempt added in v0.18.0

func PairedTerminalHostExempt(dir func() string) func(*http.Request) bool

PairedTerminalHostExempt lets GuardBrowser pass a DNS-named Host for terminal requests, the third exemption beside the origin and mirror ones — but a stricter probe than either.

The other two ask only "does the gate have anything to check" with an empty bearer, and let the gate answer everything else. This one also refuses a bearer that authenticates for another surface: a serve or origin token on the terminal route is not exempted, so it dies at the guard as forbidden_host instead of learning that a shell endpoint exists. That is the GDK-863 ruling taken literally — a serve token may never open a shell, and there is no reason to tell it where the shell is. A request with no bearer, or an unknown one, is still exempted, so the gate can answer it 401 with a reason (an honest "pair this device" for someone who has not yet).

func WebConfig

func WebConfig(cfg *config.Config) ([]byte, error)

WebConfig renders the config document the UI fetches before mount (`GadakConfig` in web/src/lib/config.ts). Credentials never appear in it.

func WebConfigBase

func WebConfigBase(cfg *config.Config, prefix string) ([]byte, error)

WebConfigBase is WebConfig with APIBase/AuthBase prefixed (e.g. "/w/work" for a workspace mount). prefix has no trailing slash; empty means root bases.

Types

type GuardExempts added in v0.19.0

type GuardExempts struct {
	// Host widens only the Host (DNS-rebinding) check, for requests a later
	// gate authenticates by credential instead of by name — today the paired
	// origin passthrough (PairedOriginHostExempt), the paired mirror REST
	// (PairedMirrorHostExempt), and the terminal (PairedTerminalHostExempt),
	// whose Bearer requirements make the rebinding vector unmountable (a
	// browser cannot attach Authorization cross-origin without a preflight
	// this server never answers).
	Host []func(*http.Request) bool
	// Origin widens only the Origin (CSRF) check. Unlike Host exempts, an
	// Origin exempt must validate the credential itself, not just note that
	// one will be demanded later: on a serve with no pairing tokens there is
	// no later gate, and the Origin header is then the only thing between a
	// hostile page in *any* webview and this API (see
	// TestTerminalWebviewOriginCannotOpenTheSocket). Today: the packaged
	// app's webview identity with a proven pairing Bearer
	// (PairedAppOriginExempt, GDK-1120).
	Origin []func(*http.Request) bool
}

GuardExempts are the ways a request can step past one of browserGuard's name-based checks — never both by one func, because the two checks stop different attackers and an exemption argued for one is not an argument for the other.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler is the HTTP API plus optional update-check control. It implements http.Handler; mount it at "/api/".

func New

func New(db *store.DB, cfg *config.Config) *Handler

New returns the API handler. Mount it at "/api/" — the patterns below carry their full paths, so nothing strips a prefix.

func NewWithCache

func NewWithCache(db *store.DB, cfg *config.Config, cache *attachcache.Cache) *Handler

NewWithCache is New plus an attachment byte cache. `gadak serve` passes one rooted under GADAK_HOME; tests pass nil when they do not exercise attachments.

func NewWorkspace

func NewWorkspace(db *store.DB, cfg *config.Config, cache *attachcache.Cache, profile string) *Handler

NewWorkspace is NewWithCache bound to a named profile (for /w/<name>/ mounts). profile is used for runtime paths and display; it does not re-read global config.

func (*Handler) BindOriginHandler added in v0.16.0

func (h *Handler) BindOriginHandler(next http.Handler)

BindOriginHandler pins the passthrough target, replacing whatever was there. nil unbinds, which is how a caller whose session went away puts the slot back to lazy — leaving a handler pinned to a session the caller has since closed is how one persist file ends up with two stores.

Serialised with localOriginOrigin so a bind cannot race lazy construction.

func (*Handler) CheckNow added in v0.16.0

func (h *Handler) CheckNow(ctx context.Context, cacheDir string) UpdateStatus

CheckNow bypasses the 24h disk cache, hits GitHub once, and records the result for GET update/ and for bootstrap/delta. Background checks stay silent; this path is the one that reports current / error / dev.

func (*Handler) Close added in v0.16.0

func (h *Handler) Close() error

Close is Shutdown with a 3s bound — the same window cmd/gadak/serve.go uses for http.Server.Shutdown.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

func (*Handler) SetSyncStarter

func (h *Handler) SetSyncStarter(f func())

SetSyncStarter registers a function that starts the background sync loop after the first credential is saved via onboarding connect. Fired at most once. cmdServe registers this when serve starts without a credential.

func (*Handler) Shutdown added in v0.16.0

func (h *Handler) Shutdown(ctx context.Context) error

Shutdown cancels background startSyncJob work and waits for those goroutines to return, or until ctx is done. A timed-out wait returns ctx.Err(); the job may still be running and still hold a database connection. Idempotent.

Returning from the job goroutine is not enough: database/sql rolls a cancelled Tx back from a helper goroutine (Tx.awaitDone), and that helper can still hold the pool connection after runSyncJob has returned. Waiting for InUse==0 is waiting for that writer, which is the WAL leak GDK-270 actually is.

func (*Handler) SnapshotSync added in v0.16.0

func (h *Handler) SnapshotSync() progressResponse

SnapshotSync is the debug document for "what background work is running right now": the same one-shot job + activity picture that GET /api/v1/issues/sync/progress/ already returns. No new endpoint — that GET already carries it; this is the in-process form.

func (*Handler) StartUpdateCheck

func (h *Handler) StartUpdateCheck(ctx context.Context, cacheDir string)

StartUpdateCheck runs a GitHub release lookup immediately and every 24h. Results feed latest_version / release_url on bootstrap and delta when the running build is older. Records cacheDir even when disabled so a later user-initiated CheckNow still knows where the file lives. The background loop is a no-op when cfg.UpdateCheckEnabled() is false. Safe with no credential (Jira-independent). Background errors are silent.

func (*Handler) SyncActivityHooks

func (h *Handler) SyncActivityHooks() (phase func(string), progress func(fetched, changed int))

SyncActivityHooks returns the Phase and Progress callbacks a background watch loop should report through, so the UI can say what the mirror is fetching and how far along. Safe for concurrent use; nil Handler returns nil funcs (callers pass them straight into sync.Options).

func (*Handler) Terminals added in v0.18.0

func (h *Handler) Terminals() *term.Manager

Terminals is the session core, for an in-process host that carries the terminal over its own transport instead of the WebSocket below.

Gadak.app is that host (GDK-892): it mounts this Handler behind the wails asset server, where there is no TCP listener for a ws:// URL to reach, and moves the same bytes over a wails GoStream. Everything above the socket — create, list, delete, the gate — is the REST surface it already uses.

Lazy exactly as the HTTP path is: a process that never opens a terminal still constructs no manager, because this is the same call handleTerminal* makes.

type LinkedPR added in v0.17.0

type LinkedPR struct {
	Number int     `json:"number"`
	Title  string  `json:"title"`
	URL    string  `json:"url"`
	State  string  `json:"state"`
	Repo   *string `json:"repo"`
	Author *string `json:"author"`
	// LinkedBy / LinkedByID name who attached the link (dev_links actor,
	// GDK-589) — a different axis from Author: a bot linking a human's PR
	// keeps both. Absent for URL attachments, which carry no actor.
	LinkedBy   *string `json:"linked_by,omitempty"`
	LinkedByID *string `json:"linked_by_id,omitempty"`
}

prLinksFromAttachments derives the linked_prs payload from mirrored URL attachments when no plugin enrichment supplies one. The enrichment (kind='prs') stays the winner: it can carry state and author, which a bare URL cannot. LinkedPR is one GitHub pull request derived from the mirror (dev_links and/or a PR-shaped URL attachment).

func ListLinkedPRs added in v0.17.0

func ListLinkedPRs(devLinks []store.DevLink, attachments []store.DetailAttachment) []LinkedPR

ListLinkedPRs merges the two mirrored PR sources: dev_links (the origin's development panel, GDK-497 — carries a state) and PR-shaped URL attachments (GDK-495 — carry none). Deduped by URL, dev_links winning, because a stated status beats an inferred blank.

type UpdateStatus added in v0.16.0

type UpdateStatus struct {
	Current         string `json:"current"`
	Latest          string `json:"latest,omitempty"`
	URL             string `json:"release_url,omitempty"`
	Notes           string `json:"release_notes,omitempty"`
	NotesLen        int    `json:"release_notes_len"`
	CheckedAt       string `json:"checked_at,omitempty"`
	Newer           bool   `json:"newer,omitempty"`
	Status          string `json:"status,omitempty"` // newer|current|error|dev — this CheckNow
	Error           string `json:"error,omitempty"`
	LastUserCheckAt string `json:"last_user_check_at,omitempty"`
	LastUserStatus  string `json:"last_user_status,omitempty"`
}

UpdateStatus is GET/POST update/ and CheckNow: what the server currently knows about the latest published release, plus the outcome of a user-initiated check when one has run.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL