httpsrv

package
v0.0.0-...-2769864 Latest Latest
Warning

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

Go to latest
Published: Oct 6, 2026 License: Apache-2.0 Imports: 47 Imported by: 0

Documentation

Overview

Package httpsrv exposes a memory.Memory over a bearer-authed HTTP API.

POST /memory/{kind}/{ns}/{name}    body Entry  → 201 Entry
GET  /memory/{kind}/{ns}/{name}    ?limit=&since=&tag= → 200 QueryResult
POST /memory/_query/{ns}/{name}    body Query  → 200 QueryResult
POST /memory/_signal/{ns}/{name}   body Signal → 204

The scope is ALWAYS derived from the REQUEST LINE — the request body is never trusted for routing keys. It is Kind="session", ID="{ns}/{name}" from the URL, except on the keyed route, where ?pool=<type>:<id> may name a RESOURCE pool instead: still the request line, and honoured only once the session is PROVED to hold a write grant on that resource (destinationFor). A pool it holds no write grant on is refused, never downgraded to the session scope. "_scope" is reserved: scope teardown is in-process (Local.DeleteScope) and answers 405 here.

Auth: Authorization: Bearer <token>. A per-session token may read and write its OWN {ns}/{name}, and may only READ the extra scopes it was registered with; anything else is 403. The channelsd and authzd system tokens may read and append on any session; the webd system token may only read. NO system token may delete an entry — deletion is a per-session bearer's operation.

Scope is not the whole answer: WHICH Kinds a per-session credential may author is the Kind's own memory.WriteAuthority, enforced at the facade for Put and Delete. This handler's job is to tell the facade the call arrived on a session credential at all (memory.WithTokenSession).

?backend= steers the shadow backend's read source, honoured only on routes whose capability is a read and only for a known value; anything else is a 400 rather than a silent downgrade.

Index

Constants

View Source
const MaxInboundAssetBytes = assetlimits.MaxInboundAssetBytes

MaxInboundAssetBytes hard-caps a single inbound attachment upload, independent of any Channel's spec.attachments.maxSizeBytes — the operator's own absolute ceiling, matching extractord's defaultMaxBytes so the two never disagree about what "too large" means on the extracted path.

It ALIASES assetlimits.MaxInboundAssetBytes rather than defining a value: channelsd's pipeline needs the same ceiling to clamp its per-attachment limit, but pipeline is reachable from the browser-facing chat package while this one pulls in provenance (audit-signing), so pipeline must import the leaf assetlimits package directly and never this one. Without the clamp a file between the channel's limit and this cap would pass the pre-fetch check, download in full, and only then trip the ceiling — a "temporary failure" note after a wasted download instead of a cheap pre-fetch "oversize" one.

Variables

View Source
var ErrArchiveMIMEUnsupported = errors.New("httpsrv: no exploder claims this archive MIME")

ErrArchiveMIMEUnsupported is returned when extractord reports 415: no registered exploder claims the MIME. PERMANENT.

View Source
var ErrArchiveRefused = errors.New("httpsrv: archive refused")

ErrArchiveRefused is returned when extractord reports 413 or 422: the archive tripped a decompression bound, or is not a readable archive of its claimed type. PERMANENT, and deliberately distinct from ErrArchiveMIMEUnsupported — "we will not open this" and "nothing here claims to open this" lead a user to different remedies.

View Source
var ErrAttachmentMIMEUnsupported = errors.New("httpsrv: no extractor claims this attachment MIME")

ErrAttachmentMIMEUnsupported is returned when extractord reports 415: no registered backend claims the MIME. PERMANENT, and deliberately distinct from every other extraction failure, which is transient.

Functions

func NewHandler

func NewHandler(mem memory.Memory, reg *tokens.Registry, opts ...HandlerOption) http.Handler

func Run

func Run(ctx context.Context, addr string, h http.Handler) error

Run starts h on addr, blocking until the listener errors or ctx is cancelled. The bind error (e.g. EADDRINUSE) is returned VERBATIM so the caller sees the real failure rather than a silent goroutine death; http.ErrServerClosed maps to nil for clean shutdown.

Returning the error is the point: an unrecovered ListenAndServe goroutine once turned a port-bind failure into a logged line and no exit, leaving the operator running with no memory API and every client on connection-refused.

Types

type AttachmentExploder

type AttachmentExploder interface {
	// Explode POSTs body to extractord and calls yield once per member, in
	// archive order, with a live reader. An error returned by yield aborts and
	// is returned unchanged.
	//
	// ErrArchiveMIMEUnsupported (415) and ErrArchiveRefused (413/422) are
	// PERMANENT. Every other error — 5xx, timeout, connection failure — is
	// TRANSIENT, and callers must not conflate the two: the notice wording for
	// each is the opposite of the other's.
	Explode(ctx context.Context, mime string, body io.Reader, lim extract.Limits, yield func(ExplodedMember) error) (ExplodeSummary, error)
}

AttachmentExploder turns an inbound archive into its members by calling extractord. Optional, injected by internal/cmd/operator. Nil means "not configured": the inbound-asset route still stores the archive's raw bytes but reports no members, which callers MUST treat as a transient failure — never as "this archive type is unsupported".

type AttachmentExtractor

type AttachmentExtractor interface {
	// Extract reads body to EOF and returns the extracted text and a page/slide
	// count (0 when the format has none). ErrAttachmentMIMEUnsupported means
	// extractord answered 415 — no backend claims mime, a PERMANENT condition.
	// Every other error (5xx, timeout, connection failure) is TRANSIENT, and
	// callers must not conflate the two.
	Extract(ctx context.Context, mime string, body io.Reader) (text string, pages int, err error)
}

AttachmentExtractor turns raw attachment bytes into extracted text by calling extractord. Optional, injected by internal/cmd/operator. Nil means "not configured": serveInboundAsset still stores the raw bytes but reports extraction as unavailable, which the channelsd pipeline MUST treat as a transient failure — never a silently missing capability, and never "unsupported type".

type EntryDeleter

type EntryDeleter interface {
	Delete(ctx context.Context, scope memory.Scope, kind, id string) error
}

EntryDeleter is an optional interface for entry deletion. When provided via WithDeleter, the _entry DELETE route is enabled. *memory.Local satisfies this.

type EntryReindexer

type EntryReindexer interface {
	Reindex(ctx context.Context, scope memory.Scope) (int, error)
}

EntryReindexer is an optional interface for reindexing. When the Memory passed to NewHandler satisfies this (e.g. *memory.Local), the POST /memory/_reindex/{ns}/{name} route is enabled.

type ExplodeSummary

type ExplodeSummary struct {
	// Members is how many were yielded.
	Members int
	// Truncated is set when a bound stopped the walk early. A caller MUST
	// surface this: a partial archive presented as whole is the outcome the
	// streaming contract is most concerned with.
	Truncated bool
	// TruncatedReason names the bound, for a caller to state plainly.
	TruncatedReason string
	// TruncatedMember names the member whose copy was CUT, when the walk
	// stopped inside one rather than between them. Empty otherwise.
	//
	// Without it a half-file is indistinguishable from a whole one: it is
	// already on the wire, and the fan-out stores it, runs text extraction over
	// it and lists it as complete. See extract.Summary.TruncatedMember.
	TruncatedMember string
	// Skipped counts refused entries by reason. Counts, never names — a member
	// name is file content.
	Skipped map[string]int
}

ExplodeSummary reports what a whole archive did.

type ExplodedMember

type ExplodedMember struct {
	// Name is the sanitized, archive-relative path. Still untrusted text.
	Name string
	// MIME was SNIFFED from the member's bytes by the exploder, never derived
	// from Name.
	MIME string
	// Size is the uncompressed byte count the exploder produced.
	Size int64
	// IsArchive marks a member that is itself a container. It was stored and
	// NEVER opened — nesting depth is 0 — and the index must say so rather
	// than leaving a handle that looks readable.
	IsArchive bool

	// Body is the member's bytes. Valid only inside the yield callback.
	Body io.Reader
}

ExplodedMember is one entry recovered from an inbound archive.

Body is a LIVE reader positioned at the member's bytes, valid only for the duration of the yield callback that received it. It is a reader and not a []byte on purpose: an archive may expand to tens of megabytes across hundreds of members, and buffering them would put the whole expansion in the operator's heap — the cost the streaming contract exists to avoid.

type HandlerOption

type HandlerOption func(*handler)

HandlerOption mutates a handler before it's wrapped in a mux. Callers that need optional features (e.g. the /artifact route, which requires a k8s client + artifactstore) pass these to NewHandler.

func WithArtifact

func WithArtifact(c client.Client, store artifactstore.Store) HandlerOption

WithArtifact mounts /artifact/{ns}/{sess}/{render}/output, which serves rendered ArtifactRender bytes, and /artifact-bundle/{ns}/{sess}/{render}/bundle, which resolves a primary HTML artifact's same-session `artifact:HANDLE` references into a self-contained ZIP. Both are gated by the channelsd/webd system tokens.

func WithAttachmentExploder

func WithAttachmentExploder(e AttachmentExploder) HandlerOption

WithAttachmentExploder enables archive fan-out on the /inbound-asset route. Without it an archive is stored whole and reports no members.

TYPED-NIL SAFETY: the natural DI shape for "fail-closed if unset" is `var c *explodeClient; if endpoint != "" { c = New(...) }`, and passing that nil POINTER yields a non-nil interface ({type, nil}), so a `h.attachmentExploder == nil` guard would be false and the first upload would panic. This constructor defends the seam by reflection rather than trusting every future caller to remember AGENTS.md's typed-nil rule — exactly as WithAttachmentExtractor does.

func WithAttachmentExtractor

func WithAttachmentExtractor(e AttachmentExtractor) HandlerOption

WithAttachmentExtractor enables extraction on the /inbound-asset route. Without it the route still stores bytes — an upload never fails for want of an extractor — but every response reports Extracted=false, Unsupported=false, which the channelsd pipeline reads as a transient failure rather than a silent skip.

TYPED-NIL SAFETY: the natural DI shape for "fail-closed if unset" is `var c *extractordClient; if endpoint != "" { c = New(...) }`, and passing that nil POINTER yields a non-nil interface ({type, nil}), so the `h.attachmentExtractor == nil` guard would be false and the first upload would panic calling Extract on a nil receiver. Rather than trust every future caller to remember AGENTS.md's typed-nil rule, this constructor defends the seam: a nil pointer in a non-nil interface is detected by reflection and normalized to a true nil interface.

func WithColdRegistryGrace

func WithColdRegistryGrace(grace time.Duration) HandlerOption

WithColdRegistryGrace turns on the cold-start grace on the per-session bearer path: for grace after the handler is constructed, a bearer that no session is registered under is answered 503 + Retry-After (retryable) instead of 401 (permanent). The operator's in-process token registry starts empty on every process boot and the AgentSession reconciler refills it per session (controllers/agentsession/memorytoken.go reregisterMemoryToken), so a live runner writing in that window would otherwise take a terminal 401 — and fail its session with MemoryUnavailable — the moment the operator restarts (an OOM, a rollout). The memory httpclient already retries 5xx across its ~17s budget, so a 503 rides the warm-up out with no client-side change.

grace <= 0 (the default when this option is absent) disables the behavior and preserves the plain 401 — which is what every in-process test harness wants, since it Sets tokens synchronously and has no cold window. Only the operator's production wiring opts in.

func WithDeleter

func WithDeleter(d EntryDeleter) HandlerOption

WithDeleter enables the DELETE /memory/_entry/{ns}/{name} route.

func WithKG

func WithKG(kg KGQuerier) HandlerOption

WithKG enables the GET /memory/_kg/{ns}/{name} route.

func WithPools

WithPools sets the resource-pool discovery reader ServeHTTP consults, via mintPoolApprovals, to mint a session's per-pool approvals alongside its own session-scope one. Omitting this option — or passing a nil reader — leaves h.pools a true nil interface, and mintPoolApprovals's existing nil check means "no resource pools configured for this deployment": every request still gets exactly the session-scope approval it got before this option existed.

Guards against a TYPED-nil concrete reader (e.g. a nil *someClient passed as pools.RelationshipReader), which — assigned directly — would produce a non-nil INTERFACE wrapping a nil value: h.pools == nil would then read false, mintPoolApprovals would call pools.ForSession on it, and the nil pointer would be dereferenced the moment ReadRelationships tried to use it. See AGENTS.md "Nil interfaces: never assign a typed-nil pointer directly." reflect is what lets this check apply to any concrete pointer type a caller passes, not just ones this package already knows about.

func WithPreferenceAudit

func WithPreferenceAudit(w PreferenceAuditWriter) HandlerOption

WithPreferenceAudit wires the append-only audit trail every ?user-ref= read writes, resolved or not (see handlePreferencesGetForUserRef).

Nil (the default) does NOT leave ?user-ref= reachable-but-unaudited: the route refuses outright with a clear error. An unaudited subject-named disclosure is not a degraded version of this feature — it is a different, unaudited one, and this package does not silently serve it. The turn-derived route (?turn=/no query at all) is entirely unaffected: it never had an audit requirement, and still doesn't.

Guards against a TYPED-nil concrete writer the same way WithPools does (see its doc and AGENTS.md "Nil interfaces: never assign a typed-nil pointer directly") — a caller passing a nil *provenance.SigningMemory (e.g. a test's zero-value memHandlerDeps.OpSigned) must land here as a true nil interface, so h.prefAudit == nil correctly refuses ?user-ref= instead of later dereferencing a nil receiver inside Put.

func WithPreferenceWriteAudit

func WithPreferenceWriteAudit(w PreferenceAuditWriter) HandlerOption

WithPreferenceWriteAudit wires the append-only preference_write audit trail every POST /memory/_preferences_firstparty_commit append — see handlePreferencesFirstPartyCommit's doc. Same PreferenceAuditWriter shape as WithPreferenceAudit (a narrow Put-only interface): the value handed here MUST be the operator's own signing facade (a *provenance.SigningMemory over "system:operator"), never h.mem directly — preference_write is ComponentWritten AND append-only, so an unsigned Put would be refused the instant the verifier saw it carried no Provenance envelope.

Nil (the default) does NOT leave the route reachable-but-unaudited: it refuses outright with 503, mirroring WithPreferenceAudit's ?user-ref= refusal — an unaudited first-party write is a different, unaudited feature, not a degraded version of this one.

Guards against a TYPED-nil concrete writer the same way WithPreferenceAudit does (see its doc and AGENTS.md "Nil interfaces: never assign a typed-nil pointer directly") — a caller passing a nil *provenance.SigningMemory must land here as a true nil interface, so h.prefWriteAudit == nil correctly refuses the route instead of later dereferencing a nil receiver inside Put.

func WithPreferences

func WithPreferences(c client.Reader) HandlerOption

WithPreferences enables GET /memory/_preferences/{ns}/{name}, which resolves the turn-author-verified user preferences snapshot for a session's AgentClass, and POST /memory/_preferences_commit/{ns}/{name}, which channelsd calls to commit an approved set_preference save. c reads the AgentSession, AgentClass and AgentSettings CRs both routes need — a client.Reader, not a client.Client, since neither route writes a CR (the commit route writes the preference value itself via h.mem, not via c).

func WithPtTagMinter

func WithPtTagMinter(m PtTagMinter) HandlerOption

WithPtTagMinter enables POST /memory/_pttag_mint/{ns}/{name}.

func WithPtTagResolver

func WithPtTagResolver(c PtTagAccessChecker) HandlerOption

WithPtTagResolver enables POST /memory/_pttag_resolve/{ns}/{name}.

func WithPublisherKeyRegistrar

func WithPublisherKeyRegistrar(r PublisherKeyRegistrar) HandlerOption

WithPublisherKeyRegistrar enables the POST /memory/_publisher_key route, by which a system component (channelsd/authzd) registers its Ed25519 signing key for verify-on-write.

func WithReindexer

func WithReindexer(r EntryReindexer) HandlerOption

WithReindexer enables the POST /memory/_reindex/{ns}/{name} route.

func WithSubjectResolution

func WithSubjectResolution(rel subjectresolve.RelationReader) HandlerOption

WithSubjectResolution enables ?user-ref= on GET /memory/_preferences: a subject-NAMED preferences read, resolved server-side via subjectresolve.Resolve rather than the turn author. rel answers the RelationReader role a resource-shaped reference (or a trigger-author reference recursing into one) needs to consult SpiceDB's sole_user relation.

Nil (the default, option not passed) leaves h.subjectRelations a true nil interface — NOT a refusal of the whole ?user-ref= route: an email-form reference resolves without any SpiceDB lookup at all, so it still works. Only a resource-shaped reference degrades, and it degrades to an UNRESOLVED Resolution with a "not available" reason (see handlePreferencesGetForUserRef), never a panic or a 5xx — subjectresolve itself answers a nil RelationReader with subjectresolve.ErrNoRelationReader, an error by design (see that type's doc), and this package is what translates it into the softer, resolution-shaped answer a caller with nothing configured deserves.

Guards against a TYPED-nil POINTER reader the same way WithPools does (see its doc and AGENTS.md "Nil interfaces: never assign a typed-nil pointer directly"). Note the guard's limit: it sees only a nil pointer boxed directly into the interface. A VALUE-typed adapter wrapping a nil client (the operator's subjectResolveSpiceDBAdapter is a struct value) is a non-nil, non-pointer interface this guard cannot and does not inspect — that case is safe only because the operator constructs the adapter after its SpiceDB client provably exists (startup exits otherwise), not because of anything here.

type KGQuerier

type KGQuerier interface {
	SearchFacts(ctx context.Context, query string, limit int) ([]memory.KGFact, error)
	GetEntity(ctx context.Context, uuid string) (*memory.KGEntity, error)
	EntityFacts(ctx context.Context, entityUUID string) ([]memory.KGFact, error)
	RelatedEntities(ctx context.Context, entityUUID string, limit int) ([]memory.KGEntity, error)
	Communities(ctx context.Context, groupID string) ([]memory.KGCommunity, error)
}

KGQuerier is the interface for knowledge-graph query operations. When provided via WithKG, the GET /memory/_kg/{ns}/{name}?action= routes are enabled.

type PreferenceAuditWriter

type PreferenceAuditWriter interface {
	Put(ctx context.Context, e memory.Entry) (memory.Entry, error)
}

PreferenceAuditWriter appends one preference_access audit entry (see pkg/memory/kinds/preferenceaccess). Deliberately narrow — just the one method this route needs — because the value the operator hands WithPreferenceAudit MUST be its own signing facade (a *provenance.SigningMemory over "system:operator"), never h.mem directly: preference_access is ComponentWritten AND append-only, so an unsigned Put through h.mem would be refused the instant the verifier saw it carried no Provenance envelope. A narrow interface makes that substitution the caller's only real choice, rather than one more thing a wider Memory parameter would make easy to get wrong.

type PtTagAccessChecker

type PtTagAccessChecker interface {
	HasTagAccess(ctx context.Context, tagID string, session memory.NamespacedName) (bool, error)
}

PtTagAccessChecker answers whether a session may receive a tag's content — pt_tag:<tagID>#access for agentsession:<session>. Implemented by the operator, which holds the SpiceDB reader; nil disables the route (405).

type PtTagMinter

type PtTagMinter interface {
	MintPtTag(ctx context.Context, scope memory.Scope, req memory.PtTagMintRequest) (string, error)
}

PtTagMinter derives a datum's audience and records the tag. Implemented by the operator, which holds both the SpiceDB reader and the component memory credential; nil disables the route (405).

The interface deliberately does not accept an audience. Everything the caller supplies is a claim about what it TOUCHED, which the minter can and does re-derive; nothing it supplies is a claim about who may SEE it.

type PublisherKeyRegistrar

type PublisherKeyRegistrar interface {
	RegisterPublisherKey(ctx context.Context, publisher, keyID string, pub ed25519.PublicKey) error
}

PublisherKeyRegistrar persists a component publisher key. Injected by the operator (ConfigMap-backed); nil disables the endpoint (405).

Jump to

Keyboard shortcuts

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