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
- Variables
- func NewHandler(mem memory.Memory, reg *tokens.Registry, opts ...HandlerOption) http.Handler
- func Run(ctx context.Context, addr string, h http.Handler) error
- type AttachmentExploder
- type AttachmentExtractor
- type EntryDeleter
- type EntryReindexer
- type ExplodeSummary
- type ExplodedMember
- type HandlerOption
- func WithArtifact(c client.Client, store artifactstore.Store) HandlerOption
- func WithAttachmentExploder(e AttachmentExploder) HandlerOption
- func WithAttachmentExtractor(e AttachmentExtractor) HandlerOption
- func WithColdRegistryGrace(grace time.Duration) HandlerOption
- func WithDeleter(d EntryDeleter) HandlerOption
- func WithKG(kg KGQuerier) HandlerOption
- func WithPools(r pools.RelationshipReader) HandlerOption
- func WithPreferenceAudit(w PreferenceAuditWriter) HandlerOption
- func WithPreferenceWriteAudit(w PreferenceAuditWriter) HandlerOption
- func WithPreferences(c client.Reader) HandlerOption
- func WithPtTagMinter(m PtTagMinter) HandlerOption
- func WithPtTagResolver(c PtTagAccessChecker) HandlerOption
- func WithPublisherKeyRegistrar(r PublisherKeyRegistrar) HandlerOption
- func WithReindexer(r EntryReindexer) HandlerOption
- func WithSubjectResolution(rel subjectresolve.RelationReader) HandlerOption
- type KGQuerier
- type PreferenceAuditWriter
- type PtTagAccessChecker
- type PtTagMinter
- type PublisherKeyRegistrar
Constants ¶
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 ¶
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.
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.
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 Run ¶
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 ¶
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 ¶
func WithPools(r pools.RelationshipReader) HandlerOption
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).