ssf

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jun 21, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

README

go-ssf

A Go implementation of the OpenID Shared Signals Framework 1.0 — the vendor-neutral wire protocol for transporting Security Event Tokens (SETs) between an event Transmitter and an event Receiver, over both push (RFC 8935) and poll (RFC 8936) delivery.

go-ssf provides:

  • http.Handler constructors over a Transmitter interface for the server side of the framework — stream configuration, status, subjects, verification, and the poll endpoint.
  • A push-mode delivery driver that signs each event as a SET and POSTs it to the configured Receiver endpoint.
  • A Sink interface plus push http.Handler and a poll-mode Poller for the Receiver side.
  • A typed HTTP client wrapping the Transmitter endpoints, used by Receivers to manage stream configuration.
  • The full type surface for every spec-defined message, with byte-stable JSON round-trip on open extension fields.
  • /.well-known/ssf-configuration metadata document support.

The library is library-vendor-neutral: it implements the spec, nothing more. It does not include a CAEP or RISC event-payload schema, an opinion about how Transmitter and Receiver authenticate to each other beyond what the framework specifies, or a vendor-specific adapter. Those belong in downstream consumers.

Status

Pre-publication. The first tagged release will be v0.1.0. The wire surface, the codec layer, and the conformance harness against the OpenID Shared Signals Framework interop suite are settled; the public API is unstable until that tag lands. See CHANGELOG.md for what has shipped.

Spec version tracked: OpenID Shared Signals Framework 1.0 (Final, 2026), exposed as ssf.SpecVersion.

Install

go get github.com/hstern/go-ssf
import "github.com/hstern/go-ssf"

Compatibility

Architecture

The library is split into five packages, each owning one concern.

  • ssf (root) — wire types for every spec-defined message (StreamConfig, StatusResponse, AddSubjectRequest, VerificationRequest, PollRequest/PollResponse, etc.), the SETSigner / SETVerifier interfaces and their go-jose-backed implementations, the StreamStore interface, the delivery-method registry, sentinel errors, and SpecVersion.
  • transmitter — the Transmitter-side HTTP surface. Defines the Transmitter business-logic interface (one Go method per spec endpoint), the per-endpoint http.Handler constructors, the composed MuxHandler, the /.well-known/ssf-configuration handler, and the PushDriver that signs SETs and POSTs them to a Receiver per RFC 8935.
  • receiver — the Receiver-side surface. Defines the Sink interface, the PushHandler that verifies POSTed SETs and hands them to a Sink per RFC 8935, the Poller that drives a Sink against a Transmitter's poll endpoint per RFC 8936, and the VerificationChallenger that orchestrates the SSF §10 verification flow.
  • client — a typed HTTP client over the Transmitter endpoint set. Receivers use it to create/update streams, manage subjects, trigger verification, and (in poll-mode deployments) drain events.
  • memstore — an in-memory StreamStore for tests, demos, and the loopback interop harness. Production deployments back the StreamStore interface with their own storage.

Quickstart

The four blocks below cover every cell of the Transmitter+push, Transmitter+poll, Receiver+push, Receiver+poll matrix. Each block stands alone.

Transmitter — push delivery

Sign a Security Event Token and POST it to a Receiver's push endpoint. The PushDriver is stateless: a Transmitter persists undelivered events in its StreamStore and feeds them to Deliver one at a time.

package main

import (
	"context"

	"github.com/go-jose/go-jose/v4"
	ssf "github.com/hstern/go-ssf"
	"github.com/hstern/go-ssf/transmitter"
)

func push(ctx context.Context, key []byte, payload []byte) error {
	opts := (&jose.SignerOptions{}).WithType(ssf.SETMediaType)
	js, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, opts)
	if err != nil {
		return err
	}
	signer, err := ssf.NewJOSESetSigner(js)
	if err != nil {
		return err
	}

	driver := transmitter.NewPushDriver(signer)
	target := transmitter.Target{
		EndpointURL:         "https://receiver.example/events",
		AuthorizationHeader: "Bearer s3cret",
	}
	return driver.Deliver(ctx, target, payload)
}
Transmitter — poll delivery

Mount the spec's Transmitter endpoints on an http.ServeMux. The poll endpoint drains queued SETs to a Receiver that POSTs to it. Consumers implement transmitter.Transmitter over their own storage; the snippet below delegates the methods it needs to memstore.InMemoryStore and embeds NotImplementedTransmitter for everything else.

package main

import (
	"context"
	"net/http"

	ssf "github.com/hstern/go-ssf"
	"github.com/hstern/go-ssf/memstore"
	"github.com/hstern/go-ssf/transmitter"
)

type myTx struct {
	transmitter.NotImplementedTransmitter
	store *memstore.InMemoryStore
}

func (t *myTx) CreateConfig(ctx context.Context, c *ssf.StreamConfig) (*ssf.StreamConfig, error) {
	return t.store.CreateStream(ctx, c)
}

func serveTransmitter() {
	store := memstore.NewInMemoryStore()
	// Enqueue a SET the Receiver will later drain via PollEvents.
	_ = store.EnqueueSET(context.Background(), "stream-1", "eyJ.compact.jws")

	mux := transmitter.MuxHandler(&myTx{store: store}, transmitter.AlwaysAllow)
	_ = http.ListenAndServe(":8080", mux)
}
Receiver — push delivery

Accept POSTs from a Transmitter on application/secevent+jwt, verify the JWS against the Transmitter's published JWKS, and hand the payload bytes to a Sink. A nil return ack-202s the delivery; a non-nil return 503s for retry (or 400s when wrapping receiver.ErrPermanent).

package main

import (
	"context"
	"net/http"

	"github.com/go-jose/go-jose/v4"
	ssf "github.com/hstern/go-ssf"
	"github.com/hstern/go-ssf/receiver"
)

func servePush(jwks jose.JSONWebKeySet) {
	verifier := ssf.NewJOSESetVerifier(jwks)
	sink := receiver.SinkFunc(func(ctx context.Context, payload []byte) error {
		// Decode the SET claims set, route on the event type, persist.
		return nil
	})
	http.Handle("/events", receiver.PushHandler(verifier, sink))
	_ = http.ListenAndServe(":9090", nil)
}
Receiver — poll delivery

Drive a Sink against a Transmitter's poll endpoint. Poller.Run loops: POST a PollRequest, verify each returned SET, deliver to the Sink, ack consumed JTIs on the next poll. Cancel the context to stop.

package main

import (
	"context"

	"github.com/go-jose/go-jose/v4"
	ssf "github.com/hstern/go-ssf"
	"github.com/hstern/go-ssf/receiver"
)

func poll(ctx context.Context, jwks jose.JSONWebKeySet) error {
	verifier := ssf.NewJOSESetVerifier(jwks)
	sink := receiver.SinkFunc(func(ctx context.Context, payload []byte) error {
		return nil
	})
	p := receiver.NewPoller(
		"https://transmitter.example/streams/poll",
		verifier, sink,
		receiver.WithAuthorizationHeader("Bearer s3cret"),
		receiver.WithMaxEvents(50),
	)
	return p.Run(ctx)
}

Stability

Pre-1.0. The wire types (StreamConfig, StatusResponse, every spec-defined message) are pinned to the OpenID Shared Signals Framework 1.0 spec and round-trip byte-stable on open extension fields; those will not churn. The Go-surface around them (constructor signatures, option names, package boundaries) may still change before v0.1.0. Breaking changes between pre-1.0 tags are called out in CHANGELOG.md.

After v0.1.0:

  • The library SemVer is independent of the spec version; spec revisions arrive as minor bumps when they are wire-additive and as major bumps when they are not.
  • Post-1.0 major bumps live on a vN branch, no versioned subdirectory in the module path — the go-jose precedent. The main branch carries the latest major.
  • The merge style on this repo is merge commits (not squash); each PR's full review history is preserved in git log --first-parent.

Contributing

See AGENTS.md for the contributor conventions — they are written as guidance for AI coding assistants, but humans will find the same conventions useful. The short version: standard Go style (gofmt, go vet, staticcheck, golangci-lint all run in CI), table-driven tests, and a strong preference for wire fidelity over ergonomic shortcuts. New exported API surface and new dependencies go through review.

License

Apache License 2.0. See LICENSE.

Documentation

Overview

Package ssf implements the OpenID Shared Signals Framework 1.0 wire protocol — the transport between an event Transmitter and an event Receiver for Security Event Tokens (SETs), over both push (RFC 8935) and poll (RFC 8936) delivery modes.

This package and its subpackages (transmitter, receiver, client, memstore) together form a library-vendor-neutral Go implementation of the spec at https://openid.net/specs/openid-sharedsignals-framework-1_0.html.

Pre-v0.1.0: the surface is being built out in phased work. See CHANGELOG.md for what has landed, AGENTS.md for the contributor conventions, and the per-symbol godoc for the spec section each symbol implements.

Index

Examples

Constants

View Source
const (
	// DeliveryMethodPush is the discriminator URI for SET push
	// delivery per RFC 8935 — the Transmitter HTTP-POSTs each SET to
	// the Receiver's endpoint. See spec §7.1.1 ("delivery") and
	// RFC 8935 §2.
	DeliveryMethodPush = "urn:ietf:rfc:8935"

	// DeliveryMethodPoll is the discriminator URI for SET poll
	// delivery per RFC 8936 — the Receiver HTTP-POSTs to the
	// Transmitter's poll endpoint to drain queued SETs. See
	// spec §7.1.1 ("delivery") and RFC 8936 §2.
	DeliveryMethodPoll = "urn:ietf:rfc:8936"
)

Delivery method discriminator URIs registered with IANA for the Shared Signals Framework's two built-in delivery modes. They are the values that appear in the Delivery.Method field on the wire, and the keys under which the built-in push and poll handlers will register themselves in the phase-3 delivery-method registry.

View Source
const EventTypeVerification = "https://schemas.openid.net/secevent/ssf/event-type/verification"

EventTypeVerification is the event-type URI for the Shared Signals Framework verification event, per spec §7.1.4. A Receiver POSTs a VerificationRequest to the Transmitter's verification endpoint; the Transmitter responds 200 and separately delivers a Security Event Token whose events claim is keyed by this URI and carries a VerificationEvent payload echoing the Receiver-supplied state.

Receivers match the echoed state to the request to confirm that the configured delivery channel is functioning end-to-end.

View Source
const SETMediaType = "secevent+jwt"

SETMediaType is the media type Security Event Tokens MUST carry in the JWS "typ" protected header per RFC 8417 §2.2. Strict verifiers reject SETs that present "typ": "JWT" or omit the header entirely; the library enforces the value on both sign and verify so callers don't have to remember it at every site.

View Source
const SpecVersion = "1.0"

SpecVersion is the OpenID Shared Signals Framework version this build implements. The spec reached Final in 2026; the library tracks 1.0 until the Shared Signals Framework itself ships a major.

Variables

View Source
var (
	// ErrStreamNotFound is returned by Transmitter methods when the
	// referenced stream ID does not exist. Per spec §7.1 the HTTP
	// layer maps this to 404 Not Found with an RFC 7807 problem-
	// details body.
	ErrStreamNotFound = errors.New("stream not found")

	// ErrUnauthorized is returned when the caller is not permitted
	// to perform the requested operation on the referenced stream —
	// either no credentials were presented or the scope they carry
	// does not cover the stream. Per spec §7 the HTTP layer maps
	// this to 401 Unauthorized.
	ErrUnauthorized = errors.New("unauthorized")

	// ErrInvalidConfig is returned by [Transmitter.CreateConfig] and
	// [Transmitter.UpdateConfig] when the proposed stream configuration
	// is rejected — unknown delivery method, missing required field,
	// or a value that violates the spec's validation rules. Per spec
	// §7.1.1 the HTTP layer maps this to 400 Bad Request with an
	// RFC 7807 problem-details body.
	ErrInvalidConfig = errors.New("invalid stream configuration")

	// ErrMethodReserved is returned by RegisterDeliveryMethod when a
	// caller attempts to register a method URI that the library
	// already provides as a built-in. The IANA Security Event Token
	// Delivery Methods registry (RFC 8935 §6) is the source of truth
	// for the built-in set; extension methods are welcome but MUST
	// use a distinct URI.
	ErrMethodReserved = errors.New("delivery method reserved")

	// ErrUnsupportedDelivery is returned when the negotiating side
	// advertises a delivery method this build does not recognize, or
	// when the other side selects a method outside the locally
	// advertised set. Per spec §3 the Transmitter's
	// delivery_methods_supported and the Receiver's selection MUST
	// agree.
	ErrUnsupportedDelivery = errors.New("unsupported delivery method")

	// ErrUnsupportedEvent is returned when an event type URI is not
	// among the stream's events_supported (Transmitter side) or
	// events_delivered (Receiver side). Per spec §7.1.1 a Transmitter
	// MUST NOT deliver an event type that is not in events_delivered
	// for the stream.
	ErrUnsupportedEvent = errors.New("unsupported event type")

	// ErrVerificationTimeout is returned when a verification challenge
	// is initiated but no matching verification SET arrives within
	// the configured timeout. Per spec §7.1.4 the Receiver matches
	// the challenge's state value against the SET's event payload;
	// this error covers the case where that match never happens.
	ErrVerificationTimeout = errors.New("verification timeout")

	// ErrNotImplemented is returned by the zero value of the
	// NotImplementedTransmitter helper from every Transmitter method.
	// Embedding NotImplementedTransmitter in a partial Transmitter
	// implementation makes the unimplemented methods return this
	// sentinel, which the HTTP layer maps to 501 Not Implemented.
	// The helper type itself is wired in a later phase; the sentinel
	// is declared here so the inventory is complete.
	ErrNotImplemented = errors.New("not implemented")
)

Sentinel errors for the Transmitter, Receiver, and client surfaces.

Per AGENTS.md, error sentences are lowercase, unpunctuated, and callers wrap with %w when adding context. Use errors.Is to match a sentinel through wrapping.

The set below is the full library-internal inventory. The HTTP layers (the Transmitter handlers and the client) translate between these sentinels and the wire-level RFC 7807 problem-details JSON responses described in spec §7.

Functions

func RegisterDeliveryMethod

func RegisterDeliveryMethod(methodURI string, factory DeliveryMethodFactory) error

RegisterDeliveryMethod registers a DeliveryMethodFactory for a delivery-method URI outside the IANA built-in set.

Call once per extension method, typically at consumer init time. Re-registering an already-registered extension method silently replaces the prior factory — a single consumer init owns each extension method by convention. Concurrent callers are serialized by an internal mutex.

Returns an error wrapping ErrMethodReserved if methodURI matches one of the built-in method URIs (DeliveryMethodPush, DeliveryMethodPoll): those cannot be overridden. Consumers needing different per-built-in behavior should wrap the concrete type rather than re-register the method. Compare the returned error with errors.Is.

The IANA "Security Event Token Delivery Methods" registry (RFC 8935 §6) is the source of truth for delivery methods; the library's built-in set is a snapshot, and new methods land via RegisterDeliveryMethod or additive minor library releases.

Example

ExampleRegisterDeliveryMethod registers a hypothetical extension delivery method, looks the factory back out of the registry, and demonstrates the ssf.ErrMethodReserved guard that protects the IANA built-in URIs (ssf.DeliveryMethodPush, ssf.DeliveryMethodPoll).

package main

import (
	"errors"
	"fmt"

	"github.com/hstern/go-ssf"
)

func main() {
	const methodURI = "urn:example:webhook"

	err := ssf.RegisterDeliveryMethod(methodURI, func() ssf.Delivery {
		return ssf.Delivery{Method: methodURI}
	})
	if err != nil {
		// handle err
		return
	}

	factory, ok := ssf.LookupDeliveryMethod(methodURI)
	fmt.Println("registered:", ok)
	fmt.Println("method URI:", factory().Method)

	// Attempting to re-register a built-in URI is refused with
	// [ssf.ErrMethodReserved]; consumers detect it with [errors.Is].
	err = ssf.RegisterDeliveryMethod(ssf.DeliveryMethodPush, func() ssf.Delivery {
		return ssf.Delivery{}
	})
	fmt.Println("built-in reserved:", errors.Is(err, ssf.ErrMethodReserved))

}
Output:
registered: true
method URI: urn:example:webhook
built-in reserved: true

Types

type AddSubjectRequest

type AddSubjectRequest struct {
	// Subject is the RFC 9493 Subject Identifier the Receiver is
	// asking the Transmitter to begin emitting events for on the
	// referenced stream. Required.
	Subject subjectid.SubjectIdentifier `json:"subject"`

	// Verified, when non-nil, indicates whether the Transmitter
	// should treat the subject identifier as already verified.
	// Omitted from the wire when nil.
	Verified *bool `json:"verified,omitempty"`
}

AddSubjectRequest is the body of a POST to the Transmitter's add-subject endpoint, per OpenID Shared Signals Framework 1.0 §7.1.3.

The Subject is an RFC 9493 Subject Identifier. Because Subject Identifiers are a discriminated union keyed on the "format" member, the concrete Go type is the subjectid.SubjectIdentifier interface; AddSubjectRequest.UnmarshalJSON dispatches through go-subjectid's format registry to pick the matching per-format type (AccountID, EmailID, IssSubID, OpaqueID, PhoneNumberID, DIDID, URIID, AliasesID, or an UnknownFormat carrier for extensions the registry has not seen).

Verified is optional and carries the Transmitter's hint about whether the supplied subject identifier has already been verified through some out-of-band channel. The pointer-bool shape preserves the wire distinction between "absent" (nil) and "present and false" (&false): an explicit false is meaningful per the spec, and a plain bool with omitempty would lose it.

func (*AddSubjectRequest) UnmarshalJSON

func (r *AddSubjectRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for AddSubjectRequest. It captures the "subject" member as raw JSON, then delegates to subjectid.Parse so the format-dispatch logic — and the forward- compatible UnknownFormat fallback — lives in exactly one place, owned by go-subjectid.

Per the lenient-unmarshal convention, extra members in the envelope are silently ignored; validation of the decoded subject is the caller's job via subjectid.SubjectIdentifier.Validate.

type Delivery

type Delivery struct {
	// Method is the delivery-method discriminator URI — one of
	// [DeliveryMethodPush] or [DeliveryMethodPoll] for the built-in
	// methods, or an IANA-registered URI for an extension method.
	Method string `json:"method,omitempty"`

	// EndpointURL is the absolute URL the delivery method dispatches
	// to. For push (RFC 8935) it is the Receiver endpoint the
	// Transmitter POSTs each SET to; for poll (RFC 8936) it is the
	// Transmitter endpoint the Receiver POSTs to drain queued SETs.
	EndpointURL string `json:"endpoint_url,omitempty"`

	// AuthorizationHeader, if non-empty, is the literal value of the
	// HTTP Authorization header to present on requests to
	// EndpointURL. Both built-in methods accept it; the spec leaves
	// the credential scheme to deployment.
	AuthorizationHeader string `json:"authorization_header,omitempty"`
	// contains filtered or unexported fields
}

Delivery is the per-stream delivery configuration on a StreamConfig per spec §7.1.1 — a discriminated union on Delivery.Method that selects between push (RFC 8935) and poll (RFC 8936) transport. Both built-in methods carry the same two fields, Delivery.EndpointURL and Delivery.AuthorizationHeader, so the type keeps them as exported struct fields rather than method-specific variant types.

Forward compatibility: the spec permits additional delivery methods registered with IANA. Delivery.UnmarshalJSON dispatches the JSON "method" discriminator through the package's delivery-method registry (see RegisterDeliveryMethod and LookupDeliveryMethod). A method URI that is neither built-in nor registered decodes into a Delivery whose Delivery.Unknown accessor returns a populated UnknownDelivery carrier holding the raw JSON bytes; re-encoding such a value reproduces the original payload byte-for-byte modulo JSON whitespace canonicalization. Built-in methods (push and poll) decode into a Delivery for which Delivery.Known reports true and the typed fields below are populated as usual.

func (Delivery) Known

func (d Delivery) Known() bool

Known reports whether the Delivery was decoded from a method URI in the delivery-method registry at the time of decode. A freshly-constructed zero-value Delivery returns true (it has no unknown-method history); a Delivery produced by Delivery.UnmarshalJSON from an unregistered method URI returns false.

Callers that need to branch on known versus unknown delivery methods typically check Known first and only consult Delivery.Unknown when Known returns false.

func (Delivery) MarshalJSON

func (d Delivery) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for Delivery. For a known method — the registry recognized the discriminator at decode time, or the value was constructed in code — the output is the standard struct encoding of the typed fields. For an unknown method — the Delivery was decoded from an unregistered method URI — the preserved raw bytes are emitted verbatim so the round-trip is byte-stable modulo encoding/json.Marshal's whitespace canonicalization of any json.Marshaler's output.

MarshalJSON exists here so the unknown-method carrier round-trips through encoding/json.Marshal; the full spec-order field- emission pass for the typed branch is the scope of a later codec commit, and the current typed-branch emission is the default struct encoding (struct field order).

func (Delivery) Unknown

func (d Delivery) Unknown() (UnknownDelivery, bool)

Unknown returns the UnknownDelivery carrier and true when the Delivery was decoded from a method URI the delivery-method registry did not recognize; otherwise it returns the zero value of UnknownDelivery and false. The returned carrier holds the original JSON bytes verbatim for byte-stable round-tripping.

Unknown is the companion to Delivery.Known; the typical pattern is:

if u, ok := delivery.Unknown(); ok {
	// handle unrecognized method u.Method, with u.Raw available.
}

func (*Delivery) UnmarshalJSON

func (d *Delivery) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for Delivery by peeking the "method" discriminator and dispatching through the delivery-method registry:

  • When the registry has a factory for the method URI, the factory is invoked to obtain a seed Delivery and the standard struct decoder fills in the remaining fields. The result is the canonical typed representation of the built-in (or registered-extension) delivery method.
  • When the registry has no factory for the method URI, the Delivery decodes into an unknown-method carrier: Delivery.Method is set to the discriminator, the typed EndpointURL and AuthorizationHeader fields are left zero, and the original input bytes are preserved verbatim for retrieval via Delivery.Unknown. The library never errors on an unrecognized method — that is the forward- compatibility contract recorded in CLAUDE.md.

Errors are reserved for malformed JSON (input that is not a JSON object, or whose "method" member is not a string). Extra members on a known method are silently dropped at decode per the library's lenient-unmarshal / strict-marshal posture.

type DeliveryMethodFactory

type DeliveryMethodFactory func() Delivery

DeliveryMethodFactory is the per-method factory the registry holds. Each factory returns a freshly-allocated zero-valued Delivery whose Method field is set to the discriminator URI the factory is registered under; the codec invokes the standard JSON decoder against that value to fill in the method-specific fields.

Factories registered for extension methods at consumer init time are expected to follow the same convention: return a zero-valued Delivery (or a value pre-populated with method-specific defaults). The registry treats the factory output as the seed passed to encoding/json.Unmarshal for the body of the delivery object on the wire.

func LookupDeliveryMethod

func LookupDeliveryMethod(methodURI string) (DeliveryMethodFactory, bool)

LookupDeliveryMethod returns the factory registered for the given method URI, or nil and false if no factory is registered. The codec uses LookupDeliveryMethod from Delivery.UnmarshalJSON to dispatch by method; a (nil, false) return causes the decoder to fall back to an UnknownDelivery carrier preserving the raw JSON bytes verbatim.

Concurrent calls are safe: the registry is read-mostly and guarded by a sync.RWMutex, so lookups proceed in parallel.

type HTTPError

type HTTPError struct {
	// StatusCode is the HTTP status code from the response.
	StatusCode int

	// Body is the raw response body. Preserved verbatim so callers
	// have the original bytes for logging or content-type-aware
	// rendering.
	Body []byte

	// RFC7807 is the parsed problem-details document when the
	// response body is application/problem+json and parses
	// successfully. Nil otherwise.
	RFC7807 *ProblemDetails
}

HTTPError is the client-side error returned when a Transmitter responds with a non-2xx status. It preserves the status code, the raw response body, and — when the body parses as RFC 7807 problem-details JSON — the structured ProblemDetails. Callers inspect StatusCode to decide retry behavior and consult RFC7807 for a structured Title or Detail.

The client wraps common status codes to sentinel errors before returning HTTPError (401 → ErrUnauthorized, 404 on a stream resource → ErrStreamNotFound); callers wanting the underlying HTTPError use errors.As.

func (*HTTPError) Error

func (e *HTTPError) Error() string

Error implements the error interface. When RFC 7807 problem-details are available, the message includes the Title; otherwise it falls back to the raw body (truncated for readability).

type JOSESetSigner

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

JOSESetSigner implements SETSigner over a pre-configured jose.Signer from github.com/go-jose/go-jose/v4. Consumers control the underlying signing key, signature algorithm, and any extra protected-header entries (such as "kid") by constructing the jose.Signer themselves and handing it to NewJOSESetSigner; this type only enforces the SET-specific layer (typ and alg rules) on top.

func NewJOSESetSigner

func NewJOSESetSigner(signer jose.Signer) (*JOSESetSigner, error)

NewJOSESetSigner wraps signer for Security Event Token production. The constructor probes signer once with a one-byte payload to inspect the protected header it emits, then verifies two RFC 8417 §2.2 invariants:

  • The "typ" header equals SETMediaType. Callers configure this via jose.SignerOptions.WithType(SETMediaType) before calling jose.NewSigner.
  • The "alg" header is not "none". Unsecured SETs are explicitly forbidden.

A signer that fails either check is rejected at construction time so the error surfaces during wiring rather than on the first SET emitted at runtime.

Example

ExampleNewJOSESetSigner demonstrates the typical wiring: configure a jose.Signer with the SET typ header, hand it to NewJOSESetSigner, and pair it with a NewJOSESetVerifier over the same key for the in-process or test path. Production callers swap the HMAC key for an asymmetric private key and publish the public half via the Transmitter's jwks_uri.

package main

import (
	"fmt"

	jose "github.com/go-jose/go-jose/v4"

	"github.com/hstern/go-ssf"
)

func main() {
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i)
	}

	opts := (&jose.SignerOptions{}).WithType(ssf.SETMediaType)
	joseSigner, err := jose.NewSigner(
		jose.SigningKey{Algorithm: jose.HS256, Key: key},
		opts,
	)
	if err != nil {
		panic(err)
	}

	signer, err := ssf.NewJOSESetSigner(joseSigner)
	if err != nil {
		panic(err)
	}

	verifier := ssf.NewJOSESetVerifier(jose.JSONWebKeySet{
		Keys: []jose.JSONWebKey{{Key: key, Algorithm: string(jose.HS256), Use: "sig"}},
	})

	jws, err := signer.Sign([]byte(`{"jti":"42","events":{}}`))
	if err != nil {
		panic(err)
	}

	payload, err := verifier.Verify(jws)
	if err != nil {
		panic(err)
	}

	fmt.Println(string(payload))
}
Output:
{"jti":"42","events":{}}

func (*JOSESetSigner) Sign

func (s *JOSESetSigner) Sign(payload []byte) (string, error)

Sign produces the compact JWS serialization of payload as a Security Event Token. The "typ" and "alg" invariants are enforced once at construction time; on the hot path this method delegates to the wrapped jose.Signer.

type JOSESetVerifier

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

JOSESetVerifier implements SETVerifier against a static jose.JSONWebKeySet. The key set is the Transmitter's published JWKS (spec §3, "jwks_uri"); the verifier resolves the signing key by "kid" if the JWS carries one and otherwise tries each key in the set until one validates.

JOSESetVerifier is safe for concurrent use once constructed; the embedded key set is not mutated.

func NewJOSESetVerifier

func NewJOSESetVerifier(keys jose.JSONWebKeySet) *JOSESetVerifier

NewJOSESetVerifier returns a verifier that resolves signing keys from keys. The caller is responsible for keeping keys fresh — typically by re-fetching the Transmitter's jwks_uri on a cadence and constructing a new verifier — since JOSESetVerifier does not itself perform network I/O.

func (*JOSESetVerifier) Verify

func (v *JOSESetVerifier) Verify(jwsCompact string) ([]byte, error)

Verify validates the compact JWS and returns its payload. The method enforces RFC 8417 §2.2 in three places before returning a payload:

  1. jose.ParseSigned restricts the accepted algorithms to the SET-appropriate set; "alg": "none" is rejected at parse time.
  2. The protected "typ" header MUST equal SETMediaType; "JWT" or other values are rejected so a generic JWT cannot be mistaken for a SET.
  3. jose.JSONWebSignature.Verify validates the cryptographic signature against a key resolved from the configured key set.

On any failure Verify returns a nil payload and a wrapped error naming the failed check; the original payload bytes are returned only on full success.

type PollRequest

type PollRequest struct {
	// Ack lists the JTIs of SETs the Receiver successfully consumed
	// since the previous poll. The Transmitter MUST stop redelivering
	// each acknowledged SET. The spec is silent on the ordering of
	// this array; implementations MUST NOT depend on order.
	Ack []string `json:"ack,omitempty"`

	// SetErrs lists JTIs the Receiver could not process, mapped to a
	// structured error describing why. The Transmitter's response to
	// reported errors is implementation-defined; see RFC 8936 §2.4.1.
	// JSON field name "setErrs" matches RFC 8936 verbatim.
	SetErrs map[string]SetErr `json:"setErrs,omitempty"`

	// MaxEvents caps the number of SETs the Transmitter returns in
	// this poll. A nil pointer means "no cap requested"; a non-nil
	// pointer to zero means "deliver no SETs, only honor the ack".
	// Per RFC 8936 §2.4.1.
	MaxEvents *int `json:"maxEvents,omitempty"`

	// ReturnImmediately, when non-nil and true, asks the Transmitter
	// to return the response without waiting (no long poll). A nil
	// pointer leaves the choice to the Transmitter. Per RFC 8936
	// §2.4.1.
	ReturnImmediately *bool `json:"returnImmediately,omitempty"`
}

PollRequest is the request body a Receiver POSTs to the Transmitter's poll endpoint per RFC 8936 §2.4. A poll serves two purposes simultaneously: it acknowledges SETs the Receiver has already processed from the previous poll, and it asks for more. An empty PollRequest (all fields zero) is a valid heartbeat — it acknowledges nothing and requests the Transmitter's default batch.

Field naming follows RFC 8936 verbatim. The pointer types on MaxEvents and ReturnImmediately distinguish "field absent" from "field present with zero value", which matters for round-trip fidelity: per RFC 8936 §2.4.1, maxEvents=0 is a defined value (the Receiver wants no SETs at all, only to acknowledge), and a nil pointer means "the Receiver did not pin a cap, let the Transmitter pick".

type PollResponse

type PollResponse struct {
	// Sets maps each SET's JTI to its JWS compact serialization (the
	// signed token string). An empty map is the wire-level "nothing
	// to deliver right now" response.
	Sets map[string]string `json:"sets"`

	// MoreAvailable, when non-nil and true, signals that the
	// Transmitter has additional queued SETs the Receiver should
	// poll for promptly rather than waiting out the normal cadence.
	// Per RFC 8936 §2.4.
	MoreAvailable *bool `json:"moreAvailable,omitempty"`
}

PollResponse is the body the Transmitter returns from its poll endpoint per RFC 8936 §2.4. Sets carries the queued SETs as a map from JTI to JWS compact serialization — the same opaque token the Receiver will quote in its next PollRequest.Ack once consumed.

The Sets field is intentionally tagged without omitempty: an empty poll response carries an explicit {"sets": {}} on the wire, not an absent sets key. MoreAvailable is a pointer so a nil value round-trips as field-absent, distinct from explicit "false".

type ProblemDetails

type ProblemDetails struct {
	// Type is a URI reference identifying the problem type. Per
	// RFC 7807 §3.1 the default when absent is "about:blank".
	Type string `json:"type,omitempty"`

	// Title is a short, human-readable summary of the problem type.
	Title string `json:"title,omitempty"`

	// Status is the HTTP status code generated by the origin server.
	Status int `json:"status,omitempty"`

	// Detail is a human-readable explanation specific to this
	// occurrence of the problem.
	Detail string `json:"detail,omitempty"`

	// Instance is a URI reference that identifies the specific
	// occurrence of the problem.
	Instance string `json:"instance,omitempty"`

	// Extensions carries every RFC 7807 extension member from the
	// top level of the source document, captured verbatim as the JSON
	// object whose keys are the extension names and whose values are
	// the original encoded bytes. On marshal these members are
	// emitted flat alongside the five registered fields rather than
	// nested under an "extensions" key. The JSON tag is "-" so the
	// default codec ignores the field; the custom MarshalJSON and
	// UnmarshalJSON methods on [ProblemDetails] own its wire shape.
	Extensions json.RawMessage `json:"-"`
}

ProblemDetails is the RFC 7807 problem-details JSON document used by the Transmitter for non-2xx responses (spec §7). Field naming follows RFC 7807 verbatim; the Extensions field captures any extension members the responder included beyond the registered set.

Per RFC 7807 §3.2, extension members live at the top level of the problem-details object alongside the registered members — not under a nested "extensions" key. The ProblemDetails.MarshalJSON and ProblemDetails.UnmarshalJSON methods on this type implement that flattening: on decode any member whose key is not one of the five registered names is captured verbatim into Extensions; on encode the registered members are emitted in spec-figure order followed by the extension members in the order they were captured.

Per the project's wire-fidelity posture, Extensions is json.RawMessage rather than map[string]any — interop scenarios pin exact JSON bytes and a map reorders keys on marshal. The captured bytes form a JSON object whose members are appended into the top-level object on re-encode.

func (*ProblemDetails) MarshalJSON

func (p *ProblemDetails) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for ProblemDetails. It emits the five RFC 7807 registered members in spec-figure order (type, title, status, detail, instance), skipping members whose Go value is the zero value to preserve the omit-empty semantics the struct tags advertise. After the registered members it appends the captured Extensions verbatim — each key of the Extensions object becomes a top-level key of the output, matching RFC 7807 §3.2.

Zero-valued ProblemDetails marshals to "{}". Extension keys that collide with one of the five registered names are emitted as the extension wins (last-write); callers that care about that edge should not populate both forms.

func (*ProblemDetails) UnmarshalJSON

func (p *ProblemDetails) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for ProblemDetails. It walks the input object key-by-key, routing the five RFC 7807 registered members to their typed struct fields and collecting every other top-level member verbatim into Extensions. Decode is order-preserving for the extension members so a subsequent MarshalJSON reproduces the original wire ordering of the extensions.

Per the library's lenient-unmarshal posture, unknown top-level members are kept, not dropped — they are exactly the extension members RFC 7807 §3.2 says implementations MAY publish. Validation of values (e.g. type must parse as a URI reference) is reserved for the opt-in Validate helper in a later phase.

type RemoveSubjectRequest

type RemoveSubjectRequest struct {
	// Subject is the RFC 9493 Subject Identifier the Receiver is
	// asking the Transmitter to stop emitting events for on the
	// referenced stream. Required.
	Subject subjectid.SubjectIdentifier `json:"subject"`
}

RemoveSubjectRequest is the body of a POST to the Transmitter's remove-subject endpoint, per OpenID Shared Signals Framework 1.0 §7.1.3.

Unlike AddSubjectRequest there is no "verified" member — the remove flow asks the Transmitter to stop emitting events for the named subject and has no notion of verification state.

func (*RemoveSubjectRequest) UnmarshalJSON

func (r *RemoveSubjectRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for RemoveSubjectRequest. The dispatch contract matches AddSubjectRequest.UnmarshalJSON; only the envelope shape differs.

type SETSigner

type SETSigner interface {
	// Sign returns the compact JWS serialization of payload, with the
	// Security Event Token "typ" protected header set. The payload is
	// the raw JSON bytes of the SET claims set; the implementation
	// does not parse or rewrite it.
	Sign(payload []byte) (jwsCompact string, err error)
}

SETSigner produces a JWS-compact-serialized Security Event Token from a JSON payload. Implementations abstract the key material so consumers can plug an in-process key, a KMS, or an HSM behind the same shape.

Implementations MUST set the JOSE "typ" protected header to SETMediaType per RFC 8417 §2.2 and MUST NOT sign with the "none" algorithm.

type SETVerifier

type SETVerifier interface {
	// Verify validates the compact JWS and returns the original
	// payload bytes. If signature validation, the typ header check,
	// or the alg check fails, Verify returns a non-nil error and an
	// empty payload.
	Verify(jwsCompact string) (payload []byte, err error)
}

SETVerifier validates a compact-serialized JWS as a Security Event Token and returns the raw payload bytes. The caller is responsible for decoding the payload as a SET claims set; the verifier only enforces the JWS-layer rules from RFC 8417 §2.2 (signature validity, "typ" protected header, "alg" not equal to "none").

type SetErr

type SetErr struct {
	// Err is the short error code identifying the failure class, drawn
	// from the SET Error Codes registry (RFC 8936 §6).
	Err string `json:"err"`

	// Description is an optional human-readable explanation. Operator
	// diagnostics, not machine-actionable.
	Description string `json:"description,omitempty"`
}

SetErr describes a failure the Receiver encountered while processing a single SET, reported back to the Transmitter through the PollRequest.SetErrs map per RFC 8936 §2.3. Err is a short machine-readable token (the SET delivery error code registry); the optional Description is a human-readable string for operator diagnostics.

type StatusResponse

type StatusResponse struct {
	// Status is the current lifecycle state of the stream (or of
	// the [StatusResponse.Subject] within the stream, if set).
	// Required by spec §7.1.2.
	Status StreamStatus `json:"status"`

	// Reason is a free-form human-readable explanation the
	// Transmitter MAY include alongside a [StreamStatusPaused] or
	// [StreamStatusDisabled] state per spec §7.1.2. Absent for the
	// enabled state in practice; the omitempty tag preserves
	// absence on round-trip.
	Reason string `json:"reason,omitempty"`

	// Subject, when present, scopes the response to a single
	// subject within the stream rather than the stream as a whole
	// per spec §7.1.2. Carried as [json.RawMessage] for wire-byte
	// fidelity; a later codec phase wires Subject Identifier
	// typing without changing this field's JSON name.
	Subject json.RawMessage `json:"subject,omitempty"`
}

StatusResponse is the body a Transmitter returns from the stream status endpoint per OpenID Shared Signals Framework 1.0 §7.1.2. The Receiver issues GET with a stream_id query parameter and receives this object describing the stream's current state.

StatusResponse.Subject is present only when the response scopes the status to a single subject within the stream rather than the stream as a whole. The field is kept as json.RawMessage so this file does not pull in the Subject Identifier dependency that lands in a later phase — codec wiring promotes it to a typed Subject Identifier without changing this struct's exported name.

type StatusUpdateRequest

type StatusUpdateRequest struct {
	// Status is the requested lifecycle state. Required by spec
	// §7.1.2.
	Status StreamStatus `json:"status"`

	// Reason is an optional human-readable rationale the Receiver
	// supplies alongside the request — for example, the operator
	// note motivating a pause. Preserved verbatim on round-trip.
	Reason string `json:"reason,omitempty"`

	// Subject, when present, scopes the requested transition to a
	// single subject within the stream rather than the stream as
	// a whole. Carried as [json.RawMessage] for wire-byte fidelity
	// pending the Subject Identifier wiring in a later phase.
	Subject json.RawMessage `json:"subject,omitempty"`
}

StatusUpdateRequest is the body a Receiver POSTs to the stream status endpoint to request a lifecycle transition per OpenID Shared Signals Framework 1.0 §7.1.2. The Transmitter MAY honor, delay, or refuse the request; the resulting state is returned in a StatusResponse (potentially asynchronously, with subsequent GETs reflecting the converged state).

The shape mirrors StatusResponse: StatusUpdateRequest.Status names the requested state, StatusUpdateRequest.Reason carries an optional human-readable rationale, and StatusUpdateRequest.Subject scopes the request to a single subject when set. Subject is json.RawMessage for the same reason as on StatusResponse.

type StreamConfig

type StreamConfig struct {
	// StreamID is the server-assigned opaque identifier for the
	// stream. Clients omit it when creating a stream and receive it
	// in the response; subsequent operations on the stream use it as
	// a query parameter rather than a body field.
	StreamID string `json:"stream_id,omitempty"`

	// Iss is the Transmitter's issuer URI per spec §7.1.1. Required
	// at the marshal boundary.
	Iss string `json:"iss,omitempty"`

	// Aud is the audience identifier(s) for SETs on this stream. The
	// spec permits a single string or a JSON array of strings; the
	// field is [json.RawMessage] to preserve the exact wire shape
	// across a round-trip. Required at the marshal boundary.
	Aud json.RawMessage `json:"aud,omitempty"`

	// EventsSupported is the set of event-type URIs the Transmitter
	// can deliver on this stream. Optional in the request from the
	// Receiver; returned by the Transmitter in the response.
	EventsSupported []string `json:"events_supported,omitempty"`

	// EventsRequested is the set of event-type URIs the Receiver
	// wants delivered. Spec-required and MUSTed to be non-empty;
	// non-emptiness is checked by Validate, not by the type or codec.
	EventsRequested []string `json:"events_requested,omitempty"`

	// EventsDelivered is the intersection of EventsSupported and
	// EventsRequested — the set the Transmitter has actually agreed
	// to deliver. Server-set; clients should not populate it on
	// create.
	EventsDelivered []string `json:"events_delivered,omitempty"`

	// Delivery carries the per-stream delivery configuration — push
	// or poll, plus the endpoint URL and optional authorization
	// header. See [Delivery]. Required at the marshal boundary.
	Delivery Delivery `json:"delivery"`

	// MinVerificationInterval is the minimum number of seconds the
	// Transmitter will allow between verification challenges. Zero
	// means "unset" on the wire.
	MinVerificationInterval int `json:"min_verification_interval,omitempty"`

	// Format is the Receiver's preferred RFC 9493 Subject Identifier
	// format name. Optional.
	Format string `json:"format,omitempty"`
}

StreamConfig is the Shared Signals Framework Stream Configuration object per spec §7.1.1 — the canonical description of an event stream agreed between a Transmitter and a Receiver. The same shape is returned by the configuration endpoint on GET, accepted (minus StreamConfig.StreamID) on POST to create a stream, and accepted as a partial document on PATCH to update a stream.

Field semantics follow the spec literally:

  • StreamID is server-assigned; clients omit it on create and receive it back in the response.
  • Iss is the Transmitter's issuer URI and matches the TransmitterConfig.Issuer of the well-known metadata document.
  • Aud is the audience identifier(s) for SETs delivered on this stream. The spec permits either a single string or a JSON array of strings; this library represents the field as json.RawMessage so the exact bytes round-trip unchanged. The marshal direction does not normalize the two forms — callers who construct a StreamConfig in code are responsible for the JSON shape they want on the wire. Consumers who need a typed view can JSON-unmarshal Aud into either a string or a []string themselves.
  • EventsSupported, EventsRequested, and EventsDelivered are slices of event-type URI strings. EventsRequested is spec- required on the Receiver→Transmitter direction and MUST be non-empty per spec §7.1.1; that constraint is enforced in a later opt-in Validate function, not here at the type boundary, per the library's lenient-unmarshal / strict-marshal posture.
  • Delivery carries the push/poll discriminated union — see the Delivery godoc.
  • MinVerificationInterval, if non-zero, is the minimum number of seconds between verification challenges the Transmitter will honor. Zero means "unset" on the wire.
  • Format, if non-empty, is the Receiver's preferred Subject Identifier format (an RFC 9493 format name).

JSON encoding follows the default encoding/json rules with the tags below. The nested Delivery value carries its own Delivery.UnmarshalJSON and Delivery.MarshalJSON that dispatch the discriminator through the delivery-method registry, so a StreamConfig containing an unrecognized delivery method still round-trips through encoding/json without loss. A later codec pass will pin field-emission order on StreamConfig itself for byte-stable interop fixtures; the current encoding is the default struct field order.

type StreamStatus

type StreamStatus string

StreamStatus is the lifecycle state of a single SSF stream per OpenID Shared Signals Framework 1.0 §7.1.2. A Receiver reads the current status from the Transmitter's status endpoint and may request a transition via StatusUpdateRequest; the Transmitter returns the resulting state — possibly delayed or refused — in a StatusResponse.

The wire form is a JSON string drawn from a closed three-value enum. Library code treats the type as an opaque string for round-trip purposes — unknown values decode and re-encode as-is — and an opt-in Validate helper that lands in a later phase rejects values outside the spec set on the send side.

const (
	// StreamStatusEnabled marks a stream as actively delivering
	// events. Subjects added to the stream produce SETs that the
	// Transmitter pushes or makes available for polling.
	StreamStatusEnabled StreamStatus = "enabled"

	// StreamStatusPaused marks a stream as temporarily halted. The
	// Transmitter retains queued events and resumes delivery on a
	// transition back to [StreamStatusEnabled]; the spec leaves the
	// retention window implementation-defined.
	StreamStatusPaused StreamStatus = "paused"

	// StreamStatusDisabled marks a stream as administratively
	// stopped. The Transmitter MAY drop queued events; a Receiver
	// that wants delivery resumed transitions the stream back to
	// [StreamStatusEnabled] and accepts that events generated while
	// disabled may be lost.
	StreamStatusDisabled StreamStatus = "disabled"
)

The three StreamStatus values defined by OpenID Shared Signals Framework 1.0 §7.1.2.

The spec does not define additional values; any future extension would arrive as a spec revision rather than a registry, so the list is closed by construction.

type StreamStore

type StreamStore interface {
	// CreateStream persists a new stream configuration and returns the
	// stored value. The store assigns [StreamConfig.StreamID] when the
	// caller leaves it empty; an implementation MAY accept a caller-
	// supplied StreamID for migrations or fixtures, but the canonical
	// flow has the store mint it. The returned [*StreamConfig] is the
	// authoritative post-create view (including any server-set fields
	// such as EventsDelivered).
	CreateStream(ctx context.Context, cfg *StreamConfig) (*StreamConfig, error)

	// GetStream returns the stored configuration for streamID. The
	// store MUST return [ErrStreamNotFound] when no such stream exists.
	// The returned pointer is owned by the caller and SHOULD NOT alias
	// internal storage — implementations either copy on read or
	// document the aliasing constraint explicitly.
	GetStream(ctx context.Context, streamID string) (*StreamConfig, error)

	// ListStreams returns a page of stored stream configurations and
	// an opaque continuation cursor. An empty pageToken selects the
	// first page; an empty nextToken in the return signals the final
	// page. Implementations are free to return all streams in a single
	// page (and an empty nextToken) until the volume warrants pagination.
	ListStreams(ctx context.Context, pageToken string) (configs []*StreamConfig, nextToken string, err error)

	// UpdateStream replaces the stored configuration for the stream
	// named by [StreamConfig.StreamID]. The store MUST return
	// [ErrStreamNotFound] when no stream with that ID exists; it MUST
	// NOT create a stream as a side effect of update. The returned
	// [*StreamConfig] is the post-update view.
	UpdateStream(ctx context.Context, cfg *StreamConfig) (*StreamConfig, error)

	// DeleteStream removes the stream with the given ID. It is
	// idempotent: a missing stream is not an error. This matches the
	// HTTP DELETE idiom and lets clients retry on network failures
	// without distinguishing "succeeded once" from "already gone".
	DeleteStream(ctx context.Context, streamID string) error

	// GetStreamStatus returns the lifecycle status for the stream.
	// When subject is non-empty, the returned status is scoped to that
	// subject within the stream rather than the stream as a whole per
	// spec §7.1.2; when subject is empty the response is the whole-
	// stream status. The store MUST return [ErrStreamNotFound] when
	// the stream does not exist. The subject parameter is the verbatim
	// Subject Identifier JSON bytes — the store compares them as JSON
	// values, not byte-equal strings, so semantically equivalent
	// encodings (whitespace, key order on the inner subject members)
	// resolve to the same per-subject entry.
	GetStreamStatus(ctx context.Context, streamID string, subject json.RawMessage) (*StatusResponse, error)

	// SetStreamStatus applies the requested lifecycle transition and
	// returns the resulting status. The Transmitter MAY honor, delay,
	// or refuse the request per spec §7.1.2; the store records the
	// outcome the Transmitter chose. When [StatusUpdateRequest.Subject]
	// is non-empty the update is scoped to that subject within the
	// stream. The store MUST return [ErrStreamNotFound] when the
	// stream does not exist.
	SetStreamStatus(ctx context.Context, streamID string, req *StatusUpdateRequest) (*StatusResponse, error)

	// AddSubject records the subject as a member of the stream's
	// active set, after which the Transmitter MAY emit SETs about it.
	// The store MUST return [ErrStreamNotFound] when the stream does
	// not exist. Adding a subject that is already a member is a no-op
	// and MUST NOT return an error — the operation is idempotent at
	// the store boundary for the same reasons DELETE is.
	AddSubject(ctx context.Context, streamID string, req *AddSubjectRequest) error

	// RemoveSubject removes the subject from the stream's active set.
	// The store MUST return [ErrStreamNotFound] when the stream does
	// not exist. Removing a subject that is not a member is a no-op
	// and MUST NOT return an error; idempotence again matches the
	// HTTP DELETE shape the Transmitter's subjects endpoint exposes.
	RemoveSubject(ctx context.Context, streamID string, req *RemoveSubjectRequest) error

	// EnqueueSET appends a signed SET (in JWS compact serialization)
	// to the stream's poll-mode queue. The Transmitter calls this when
	// it generates an event for a poll-delivery stream; the matching
	// drain side lands with the poll handler in a later phase. The
	// store MUST return [ErrStreamNotFound] when the stream does not
	// exist. The jwsCompact argument is treated as an opaque token —
	// the store does not parse or verify it.
	EnqueueSET(ctx context.Context, streamID, jwsCompact string) error
}

StreamStore is the persistence boundary every Transmitter implementation sits on top of per OpenID Shared Signals Framework 1.0 §7.1. The Transmitter HTTP handlers translate request bodies into typed arguments and route the work through this interface; the store owns the mapping from stream identifiers to configuration, lifecycle state, subject membership, and queued Security Event Tokens awaiting poll-mode delivery.

The interface is small but covers every persistent surface the spec exposes:

Error contract. Methods named after a single resource return ErrStreamNotFound when the referenced stream does not exist. Delete is the exception: it MUST be idempotent (no error when the stream is absent) to match the HTTP DELETE idiom and to keep clients out of retry loops on transient duplicates. Implementations MUST wrap any transport- or storage-layer error so that errors.Is against the sentinels above still matches.

Concurrency. Every method takes a context.Context and MAY be called concurrently from multiple goroutines. Implementations choose their own locking; the in-tree github.com/hstern/go-ssf/memstore implementation uses a single mutex (correctness over throughput, suited to tests and demos).

Pagination. StreamStore.ListStreams uses an opaque page-token cursor: an empty token selects the first page, and the returned nextToken is empty when the cursor is exhausted. The token format is implementation-defined; callers MUST NOT inspect or construct tokens, only echo them back verbatim. The interface deliberately does not commit to a page-size argument — implementations pick a default that matches their backing store's natural batch size, and add a typed option in a follow-up if a caller surfaces a need.

type TransmitterConfig

type TransmitterConfig struct {
	// Issuer is the Transmitter's identifier URI. Required by spec §3.
	Issuer string `json:"issuer"`

	// JWKSURI is the JWKS endpoint serving the Transmitter's SET
	// signing keys. Spec-recommended; omitted when the Transmitter
	// distributes keys through a non-JWKS channel.
	JWKSURI string `json:"jwks_uri,omitempty"`

	// DeliveryMethodsSupported is the list of delivery-method URIs
	// the Transmitter implements — urn:ietf:rfc:8935 for push,
	// urn:ietf:rfc:8936 for poll, plus any registered extensions.
	// Required by spec §3.
	DeliveryMethodsSupported []string `json:"delivery_methods_supported"`

	// ConfigurationEndpoint is the absolute URL of the stream
	// configuration endpoint (spec §7.1.1). Absent when the
	// Transmitter does not expose stream configuration over HTTP.
	ConfigurationEndpoint string `json:"configuration_endpoint,omitempty"`

	// StatusEndpoint is the absolute URL of the stream status
	// endpoint (spec §7.1.2). Absent when not implemented.
	StatusEndpoint string `json:"status_endpoint,omitempty"`

	// AddSubjectEndpoint is the absolute URL of the add-subject
	// endpoint (spec §7.1.3.1). Absent when not implemented.
	AddSubjectEndpoint string `json:"add_subject_endpoint,omitempty"`

	// RemoveSubjectEndpoint is the absolute URL of the remove-subject
	// endpoint (spec §7.1.3.2). Absent when not implemented.
	RemoveSubjectEndpoint string `json:"remove_subject_endpoint,omitempty"`

	// VerificationEndpoint is the absolute URL of the verification
	// endpoint (spec §7.1.4). Absent when not implemented.
	VerificationEndpoint string `json:"verification_endpoint,omitempty"`

	// CriticalSubjectMembers lists subject-member names the
	// Transmitter requires Receivers to support per spec §3. An
	// empty list and absence carry different wire shapes; the
	// omitempty tag preserves the absence form on round-trip.
	CriticalSubjectMembers []string `json:"critical_subject_members,omitempty"`

	// SpecVersion is the Shared Signals Framework spec version this
	// Transmitter implements per spec §3. For a Transmitter built on
	// this library the natural value is [SpecVersion].
	SpecVersion string `json:"spec_version,omitempty"`

	// AuthorizationSchemes describes the authorization mechanisms
	// the Transmitter accepts on its stream-management endpoints
	// per spec §3. Left opaque as [json.RawMessage] so the wire
	// shape — an array of objects whose contents the spec leaves
	// to registries — round-trips byte-for-byte regardless of which
	// scheme-specific keys a deployment publishes.
	AuthorizationSchemes json.RawMessage `json:"authorization_schemes,omitempty"`
}

TransmitterConfig is the well-known metadata document a Transmitter publishes at /.well-known/ssf-configuration per OpenID Shared Signals Framework 1.0 §3. A Receiver fetches the document to discover the Transmitter's identity, signing keys, supported delivery methods, and the absolute URLs of the stream-management endpoints it implements.

Two fields are required by the spec: TransmitterConfig.Issuer and TransmitterConfig.DeliveryMethodsSupported. Every other field is optional — endpoint URLs are absent when the Transmitter does not implement the corresponding endpoint, and the capability / authorization metadata carries whatever the deployment chooses to advertise.

JSON tags match the wire names from §3 verbatim. The open-extension fields use json.RawMessage rather than map[string]any so the wire bytes round-trip unchanged — interop fixtures pin exact JSON, and Go's map iteration order would reshuffle nested objects on re-marshal.

Validation lives at the marshal boundary and in an opt-in Validate helper that lands in a later phase; this type's json.Marshal and json.Unmarshal paths are deliberately lenient. Per AGENTS.md the library decodes whatever the wire produced and rejects only on send.

type UnknownDelivery

type UnknownDelivery struct {
	// Method is the value of the JSON "method" member as decoded —
	// the discriminator URI the library did not recognize.
	Method string

	// Raw is the entire JSON object that was decoded, byte-for-byte
	// as it appeared on the wire. Re-encoding through
	// [Delivery.MarshalJSON] returns these bytes unchanged modulo
	// the whitespace canonicalization [encoding/json.Marshal]
	// performs on any [json.Marshaler]'s output.
	Raw json.RawMessage
}

UnknownDelivery is the forward-compatibility carrier for delivery methods that are neither in the library's built-in set nor registered via RegisterDeliveryMethod at the time the wire payload is decoded. A library compiled today, decoding a StreamConfig that uses a delivery method the IANA registry adds in a future revision, produces an UnknownDelivery rather than an error so the value can still round-trip and callers can branch on whatever subset of methods they actually understand.

The wire bytes are preserved verbatim in UnknownDelivery.Raw so re-encoding through Delivery.MarshalJSON (added in a later commit) produces the original payload byte-for-byte, modulo JSON whitespace canonicalization performed by encoding/json.Marshal. Raw is intentionally a json.RawMessage rather than a map[string]any: interop scenarios often pin exact JSON bytes, and a map reorders its keys on every encode.

Access an UnknownDelivery via Delivery.Unknown, which returns the carrier and a boolean indicating whether the Delivery was produced from an unrecognized method URI. Delivery.Known reports the inverse.

Example

ExampleUnknownDelivery demonstrates the forward-compatibility carrier: a ssf.StreamConfig whose delivery method is not in the library's built-in set and was not registered via ssf.RegisterDeliveryMethod decodes into a ssf.Delivery whose ssf.Delivery.Unknown accessor returns the raw JSON bytes verbatim.

package main

import (
	"encoding/json"
	"fmt"

	"github.com/hstern/go-ssf"
)

func main() {
	payload := []byte(`{"method":"urn:example:unknown-2099","endpoint_url":"https://r.example/in"}`)

	var d ssf.Delivery
	if err := json.Unmarshal(payload, &d); err != nil {
		// handle err
		return
	}

	unknown, isUnknown := d.Unknown()
	fmt.Println("known:", d.Known())
	fmt.Println("unknown:", isUnknown)
	fmt.Println("method:", unknown.Method)

}
Output:
known: false
unknown: true
method: urn:example:unknown-2099

type ValidationError

type ValidationError struct {
	// Rule names the validation rule that failed (for example
	// "events_requested non-empty" or "method required").
	Rule string

	// Field names the JSON field (or dotted path) that triggered
	// the failure. Empty when the rule applies to the document as
	// a whole.
	Field string

	// Reason is a human-readable explanation suitable for inclusion
	// in a log line or an RFC 7807 problem-details Detail.
	Reason string
}

ValidationError is the structural-validation failure returned by opt-in Validate helpers on spec types. Each instance pins the rule that failed, the field that triggered it, and a human-readable reason. It implements [error] via a stable format so callers can compare or log it directly.

ValidationError is intentionally a concrete struct (not an interface) — callers commonly type-assert and inspect the three fields. The library wraps ValidationError values with %w when composing them with higher-level errors so errors.As still recovers the original.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error implements the error interface. The format is stable; tests and log scrapers can rely on it.

type VerificationEvent

type VerificationEvent struct {
	// State mirrors the Receiver-supplied state from the originating
	// [VerificationRequest]. Omitted from the wire form when empty.
	State string `json:"state,omitempty"`
}

VerificationEvent is the payload carried under the events claim of a verification Security Event Token, keyed by EventTypeVerification, per spec §7.1.4. It mirrors the State supplied on the originating VerificationRequest, or is empty when the Receiver omitted state.

type VerificationRequest

type VerificationRequest struct {
	// State is the optional opaque echo string. Omitted from the
	// wire form when empty.
	State string `json:"state,omitempty"`
}

VerificationRequest is the body a Receiver POSTs to the Transmitter's verification endpoint per spec §7.1.4. The request itself carries no subject — it is a control-plane probe that asks the Transmitter to deliver a verification event over the stream's configured delivery method.

The optional State is an opaque echo string chosen by the Receiver. The Transmitter copies it verbatim into the VerificationEvent carried by the resulting Security Event Token, letting the Receiver correlate the inbound event to the outbound request — for example when several verification probes are in flight concurrently.

Directories

Path Synopsis
Package client is the Receiver-side HTTP client for the Transmitter endpoints defined by OpenID Shared Signals Framework 1.0 §7.
Package client is the Receiver-side HTTP client for the Transmitter endpoints defined by OpenID Shared Signals Framework 1.0 §7.
internal
specfixtures
Package specfixtures embeds canonical JSON payloads for every message type defined by OpenID Shared Signals Framework 1.0 and exposes them as a embed.FS so conformance tests — both the in-package round-trip suite and any out-of-package interop driver that wants to reuse the same wire bytes — operate on a single shared set of payloads.
Package specfixtures embeds canonical JSON payloads for every message type defined by OpenID Shared Signals Framework 1.0 and exposes them as a embed.FS so conformance tests — both the in-package round-trip suite and any out-of-package interop driver that wants to reuse the same wire bytes — operate on a single shared set of payloads.
Package memstore provides an in-memory ssf.StreamStore for tests, demos, and the conformance harness.
Package memstore provides an in-memory ssf.StreamStore for tests, demos, and the conformance harness.
Package receiver implements the Receiver half of the OpenID Shared Signals Framework 1.0 wire protocol — the side that consumes Security Event Tokens emitted by a Transmitter.
Package receiver implements the Receiver half of the OpenID Shared Signals Framework 1.0 wire protocol — the side that consumes Security Event Tokens emitted by a Transmitter.
Package transmitter provides HTTP handlers and supporting types for the Shared Signals Framework Transmitter role.
Package transmitter provides HTTP handlers and supporting types for the Shared Signals Framework Transmitter role.

Jump to

Keyboard shortcuts

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